67 Commits

Author SHA1 Message Date
calvincao
62585f4b2e Add electrode sheet and bottle rack classes; enhance coin cell deck setup with new resources and configurations 2025-11-09 21:22:27 +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
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
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
55 changed files with 2696 additions and 2630 deletions

View File

@@ -99,7 +99,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine_four",
"type": "MagazineHolder_4",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -140,7 +140,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -235,7 +235,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -330,7 +330,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -425,7 +425,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -523,7 +523,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine_four",
"type": "MagazineHolder_4",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -564,7 +564,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -659,7 +659,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -754,7 +754,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -849,7 +849,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -949,7 +949,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -992,7 +992,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1087,7 +1087,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1182,7 +1182,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1277,7 +1277,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1372,7 +1372,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1467,7 +1467,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1567,7 +1567,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -1610,7 +1610,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1705,7 +1705,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1800,7 +1800,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1895,7 +1895,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1990,7 +1990,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2085,7 +2085,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2185,7 +2185,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -2228,7 +2228,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2323,7 +2323,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2418,7 +2418,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2513,7 +2513,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2608,7 +2608,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2703,7 +2703,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2803,7 +2803,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -2846,7 +2846,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2941,7 +2941,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3036,7 +3036,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3131,7 +3131,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3226,7 +3226,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3321,7 +3321,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3421,7 +3421,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -3464,7 +3464,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3559,7 +3559,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3654,7 +3654,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3749,7 +3749,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3844,7 +3844,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3939,7 +3939,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4039,7 +4039,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -4082,7 +4082,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4177,7 +4177,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4272,7 +4272,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4367,7 +4367,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4462,7 +4462,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4557,7 +4557,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,

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": []
}

View File

@@ -3,35 +3,94 @@
{
"id": "bioyond_cell_workstation",
"name": "配液分液工站",
"children": [
],
"parent": null,
"children": [
"YB_Bioyond_Deck"
],
"type": "device",
"class": "bioyond_cell",
"config": {
"protocol_type": [],
"station_resource": {}
"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"
],
"parent": null,
"type": "device",
"class":"coincellassemblyworkstation_device",
"position": {
"x": 600,
"y": 400,
"x": -600,
"y": -400,
"z": 0
},
"config": {
"debug_mode": false,
"deck": {
"data": {
"_resource_child_name": "YB_YH_Deck",
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck"
}
},
"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": []

View File

@@ -3,7 +3,8 @@
"""
import asyncio
from typing import Dict, Any, Optional, List
from typing import Dict, Any, List
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class SmartPumpController:
@@ -14,6 +15,8 @@ class SmartPumpController:
适用于实验室自动化系统中的液体处理任务。
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = "smart_pump_01", port: str = "/dev/ttyUSB0"):
"""
初始化智能泵控制器
@@ -30,6 +33,9 @@ class SmartPumpController:
self.calibration_factor = 1.0
self.pump_mode = "continuous" # continuous, volume, rate
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def connect_device(self, timeout: int = 10) -> bool:
"""
连接到泵设备
@@ -90,7 +96,7 @@ class SmartPumpController:
pump_time = (volume / flow_rate) * 60 # 转换为秒
self.current_flow_rate = flow_rate
await asyncio.sleep(min(pump_time, 3.0)) # 模拟泵送过程
await self._ros_node.sleep(min(pump_time, 3.0)) # 模拟泵送过程
self.total_volume_pumped += volume
self.current_flow_rate = 0.0
@@ -170,6 +176,8 @@ class AdvancedTemperatureController:
适用于需要精确温度控制的化学反应和材料处理过程。
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, controller_id: str = "temp_controller_01"):
"""
初始化温度控制器
@@ -185,6 +193,9 @@ class AdvancedTemperatureController:
self.pid_enabled = True
self.temperature_history: List[Dict] = []
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def set_target_temperature(self, temperature: float, rate: float = 10.0) -> bool:
"""
设置目标温度
@@ -238,7 +249,7 @@ class AdvancedTemperatureController:
}
)
await asyncio.sleep(step_time)
await self._ros_node.sleep(step_time)
# 保持历史记录不超过100条
if len(self.temperature_history) > 100:
@@ -330,6 +341,8 @@ class MultiChannelAnalyzer:
常用于光谱分析、电化学测量等应用场景。
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, analyzer_id: str = "analyzer_01", channels: int = 8):
"""
初始化多通道分析仪
@@ -344,6 +357,9 @@ class MultiChannelAnalyzer:
self.is_measuring = False
self.sample_rate = 1000 # Hz
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def configure_channel(self, channel: int, enabled: bool = True, unit: str = "V") -> bool:
"""
配置通道
@@ -376,7 +392,7 @@ class MultiChannelAnalyzer:
# 模拟数据采集
measurements = []
for second in range(duration):
for _ in range(duration):
timestamp = asyncio.get_event_loop().time()
frame_data = {}
@@ -391,7 +407,7 @@ class MultiChannelAnalyzer:
measurements.append({"timestamp": timestamp, "data": frame_data})
await asyncio.sleep(1.0) # 每秒采集一次
await self._ros_node.sleep(1.0) # 每秒采集一次
self.is_measuring = False
@@ -465,6 +481,8 @@ class AutomatedDispenser:
集成称重功能,确保分配精度和重现性。
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, dispenser_id: str = "dispenser_01"):
"""
初始化自动分配器
@@ -479,6 +497,9 @@ class AutomatedDispenser:
self.container_capacity = 1000.0 # mL
self.precision_mode = True
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def move_to_position(self, x: float, y: float, z: float) -> bool:
"""
移动到指定位置
@@ -517,7 +538,7 @@ class AutomatedDispenser:
if viscosity == "high":
dispense_time *= 2 # 高粘度液体需要更长时间
await asyncio.sleep(min(dispense_time, 5.0)) # 最多等待5秒
await self._ros_node.sleep(min(dispense_time, 5.0)) # 最多等待5秒
self.dispensed_total += volume

View File

@@ -15,9 +15,9 @@ lab_registry.setup()
type_mapping = {
"加样头(大)": ("YB_jia_yang_tou_da_1X1_carrier", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
"加样头(大)": ("YB_jia_yang_tou_da", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
"": ("YB_1BottleCarrier", "3a190ca1-2add-2b23-f8e1-bbd348b7f790"),
"配液瓶(小)板": ("YB_6x_SmallSolutionBottleCarrier", "3a190c8b-3284-af78-d29f-9a69463ad047"),
"配液瓶(小)板": ("YB_peiyepingxiaoban", "3a190c8b-3284-af78-d29f-9a69463ad047"),
"配液瓶(小)": ("YB_pei_ye_xiao_Bottler", "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"),
}

View File

@@ -13,7 +13,7 @@ def start_backend(
graph=None,
controllers_config: dict = {},
bridges=[],
without_host: bool = False,
is_slave: bool = False,
visual: str = "None",
resources_mesh_config: dict = {},
**kwargs,
@@ -32,7 +32,7 @@ def start_backend(
raise ValueError(f"Unsupported backend: {backend}")
backend_thread = threading.Thread(
target=main if not without_host else slave,
target=main if not is_slave else slave,
args=(
devices_config,
resources_config,

View File

@@ -375,15 +375,13 @@ def main():
args_dict["bridges"] = []
# 获取通信客户端仅支持WebSocket
comm_client = get_communication_client()
if "websocket" in args_dict["app_bridges"]:
args_dict["bridges"].append(comm_client)
if "fastapi" in args_dict["app_bridges"]:
args_dict["bridges"].append(http_client)
# 获取通信客户端仅支持WebSocket
if BasicConfig.is_host_mode:
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)
@@ -391,6 +389,9 @@ def main():
signal.signal(signal.SIGINT, _exit)
signal.signal(signal.SIGTERM, _exit)
comm_client.start()
else:
print_status("SlaveMode跳过Websocket连接")
args_dict["resources_mesh_config"] = {}
args_dict["resources_edge_config"] = resource_edge_info
# web visiualize 2D

View File

@@ -1,11 +1,12 @@
import json
import time
from typing import Optional, Tuple, Dict, Any
from unilabos.utils.log import logger
from unilabos.utils.type_check import TypeEncoder
def register_devices_and_resources(lab_registry):
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
"""
注册设备和资源到服务器仅支持HTTP
"""
@@ -28,6 +29,8 @@ def register_devices_and_resources(lab_registry):
resources_to_register[resource_info["id"]] = resource_info
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
if gather_only:
return devices_to_register, resources_to_register
# 注册设备
if devices_to_register:
try:

View File

@@ -421,7 +421,7 @@ 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,
@@ -1197,7 +1197,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

View File

@@ -12,6 +12,7 @@ from serial import Serial
from serial.serialutil import SerialException
from unilabos.messages import Point3D
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class GrblCNCConnectionError(Exception):
@@ -32,6 +33,7 @@ class GrblCNCInfo:
class GrblCNCAsync:
_status: str = "Offline"
_position: Point3D = Point3D(x=0.0, y=0.0, z=0.0)
_ros_node: BaseROS2DeviceNode
def __init__(self, port: str, address: str = "1", limits: tuple[int, int, int, int, int, int] = (-150, 150, -200, 0, 0, 60)):
self.port = port
@@ -58,6 +60,9 @@ class GrblCNCAsync:
self._run_future: Optional[Future[Any]] = None
self._run_lock = Lock()
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def _read_all(self):
data = self._serial.read_until(b"\n")
data_decoded = data.decode()
@@ -148,7 +153,7 @@ class GrblCNCAsync:
try:
await self._query(command)
while True:
await asyncio.sleep(0.2) # Wait for 0.5 seconds before polling again
await self._ros_node.sleep(0.2) # Wait for 0.5 seconds before polling again
status = await self.get_status()
if "Idle" in status:
@@ -214,7 +219,7 @@ class GrblCNCAsync:
self._pose_number = i
self.pose_number_remaining = len(points) - i
await self.set_position(point)
await asyncio.sleep(0.5)
await self._ros_node.sleep(0.5)
self._step_number = -1
async def stop_operation(self):
@@ -235,7 +240,7 @@ class GrblCNCAsync:
async def open(self):
if self._read_task:
raise GrblCNCConnectionError
self._read_task = asyncio.create_task(self._read_loop())
self._read_task = self._ros_node.create_task(self._read_loop())
try:
await self.get_status()

View File

@@ -2,6 +2,8 @@ import time
import asyncio
from pydantic import BaseModel
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class Point3D(BaseModel):
x: float
@@ -14,10 +16,15 @@ def d(a: Point3D, b: Point3D) -> float:
class MockCNCAsync:
_ros_node: BaseROS2DeviceNode["MockCNCAsync"]
def __init__(self):
self._position: Point3D = Point3D(x=0.0, y=0.0, z=0.0)
self._status = "Idle"
def post_create(self, ros_node):
self._ros_node = ros_node
@property
def position(self) -> Point3D:
return self._position
@@ -38,5 +45,5 @@ class MockCNCAsync:
self._position.x = current_pos.x + (position.x - current_pos.x) / 20 * (i+1)
self._position.y = current_pos.y + (position.y - current_pos.y) / 20 * (i+1)
self._position.z = current_pos.z + (position.z - current_pos.z) / 20 * (i+1)
await asyncio.sleep(move_time / 20)
await self._ros_node.sleep(move_time / 20)
self._status = "Idle"

View File

@@ -15,9 +15,12 @@ from typing import List, Optional, Dict, Any, Union, Tuple
from dataclasses import dataclass
from abc import ABC, abstractmethod
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
# 基础导入
try:
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
PYLABROBOT_AVAILABLE = True
except ImportError:
# 如果 pylabrobot 不可用,创建基础的模拟类
@@ -42,17 +45,16 @@ except ImportError:
class Well(Resource):
pass
# LaiYu_Liquid 控制器导入
try:
from .controllers.pipette_controller import (
PipetteController, TipStatus, LiquidClass, LiquidParameters
)
from .controllers.xyz_controller import (
XYZController, MachineConfig, CoordinateOrigin, MotorAxis
)
from .controllers.pipette_controller import PipetteController, TipStatus, LiquidClass, LiquidParameters
from .controllers.xyz_controller import XYZController, MachineConfig, CoordinateOrigin, MotorAxis
CONTROLLERS_AVAILABLE = True
except ImportError:
CONTROLLERS_AVAILABLE = False
# 创建模拟的控制器类
class PipetteController:
def __init__(self, *args, **kwargs):
@@ -71,17 +73,20 @@ except ImportError:
def connect_device(self):
return True
logger = logging.getLogger(__name__)
class LaiYuLiquidError(RuntimeError):
"""LaiYu_Liquid 设备异常"""
pass
@dataclass
class LaiYuLiquidConfig:
"""LaiYu_Liquid 设备配置"""
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
address: int = 1 # 设备地址
baudrate: int = 9600 # 波特率
@@ -155,7 +160,17 @@ class LaiYuLiquidDeck:
class LaiYuLiquidContainer:
"""LaiYu_Liquid 容器类"""
def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, container_type: str = "", volume: float = 0.0, max_volume: float = 1000.0, lid_height: float = 0.0):
def __init__(
self,
name: str,
size_x: float = 0,
size_y: float = 0,
size_z: float = 0,
container_type: str = "",
volume: float = 0.0,
max_volume: float = 1000.0,
lid_height: float = 0.0,
):
self.name = name
self.size_x = size_x
self.size_y = size_y
@@ -197,17 +212,22 @@ class LaiYuLiquidContainer:
def assign_child_resource(self, resource, location=None):
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
if hasattr(resource, 'name'):
self.child_resources[resource.name] = {
'resource': resource,
'location': location
}
if hasattr(resource, "name"):
self.child_resources[resource.name] = {"resource": resource, "location": location}
class LaiYuLiquidTipRack:
"""LaiYu_Liquid 吸头架类"""
def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, tip_count: int = 96, tip_volume: float = 1000.0):
def __init__(
self,
name: str,
size_x: float = 0,
size_y: float = 0,
size_z: float = 0,
tip_count: int = 96,
tip_volume: float = 1000.0,
):
self.name = name
self.size_x = size_x
self.size_y = size_y
@@ -240,10 +260,7 @@ class LaiYuLiquidTipRack:
def assign_child_resource(self, resource, location=None):
"""分配子资源到指定位置"""
self.child_resources[resource.name] = {
'resource': resource,
'location': location
}
self.child_resources[resource.name] = {"resource": resource, "location": location}
def get_module_info():
@@ -253,24 +270,17 @@ def get_module_info():
"version": "1.0.0",
"description": "LaiYu液体处理工作站模块提供移液器控制、XYZ轴控制和资源管理功能",
"author": "UniLabOS Team",
"capabilities": [
"移液器控制",
"XYZ轴运动控制",
"吸头架管理",
"板和容器管理",
"资源位置管理"
],
"dependencies": {
"required": ["serial"],
"optional": ["pylabrobot"]
}
"capabilities": ["移液器控制", "XYZ轴运动控制", "吸头架管理", "板和容器管理", "资源位置管理"],
"dependencies": {"required": ["serial"], "optional": ["pylabrobot"]},
}
class LaiYuLiquidBackend:
"""LaiYu_Liquid 硬件通信后端"""
def __init__(self, config: LaiYuLiquidConfig, deck: Optional['LaiYuLiquidDeck'] = None):
_ros_node: BaseROS2DeviceNode
def __init__(self, config: LaiYuLiquidConfig, deck: Optional["LaiYuLiquidDeck"] = None):
self.config = config
self.deck = deck # 工作台引用,用于获取资源位置信息
self.pipette_controller = None
@@ -283,6 +293,9 @@ class LaiYuLiquidBackend:
self.tip_attached = False
self.current_volume = 0.0
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def _validate_position(self, x: float, y: float, z: float) -> bool:
"""验证位置是否在安全范围内"""
try:
@@ -348,7 +361,7 @@ class LaiYuLiquidBackend:
safe_position = (
self.config.deck_width / 2, # 工作台中心X
self.config.deck_height / 2, # 工作台中心Y
self.config.safe_height # 安全高度Z
self.config.safe_height, # 安全高度Z
)
if not self._validate_position(*safe_position):
@@ -375,17 +388,12 @@ class LaiYuLiquidBackend:
try:
if CONTROLLERS_AVAILABLE:
# 初始化移液器控制器
self.pipette_controller = PipetteController(
port=self.config.port,
address=self.config.address
)
self.pipette_controller = PipetteController(port=self.config.port, address=self.config.address)
# 初始化XYZ控制器
machine_config = MachineConfig()
self.xyz_controller = XYZController(
port=self.config.port,
baudrate=self.config.baudrate,
machine_config=machine_config
port=self.config.port, baudrate=self.config.baudrate, machine_config=machine_config
)
# 连接设备
@@ -412,10 +420,10 @@ class LaiYuLiquidBackend:
async def stop(self):
"""停止设备"""
try:
if self.pipette_controller and hasattr(self.pipette_controller, 'disconnect'):
if self.pipette_controller and hasattr(self.pipette_controller, "disconnect"):
await asyncio.to_thread(self.pipette_controller.disconnect)
if self.xyz_controller and hasattr(self.xyz_controller, 'disconnect'):
if self.xyz_controller and hasattr(self.xyz_controller, "disconnect"):
await asyncio.to_thread(self.xyz_controller.disconnect)
self.is_connected = False
@@ -432,7 +440,7 @@ class LaiYuLiquidBackend:
raise LaiYuLiquidError("设备未连接")
# 模拟移动
await asyncio.sleep(0.1) # 模拟移动时间
await self._ros_node.sleep(0.1) # 模拟移动时间
self.current_position = (x, y, z)
logger.debug(f"移动到位置: ({x}, {y}, {z})")
return True
@@ -472,9 +480,11 @@ class LaiYuLiquidBackend:
pickup_z = tip_z - self.config.tip_pickup_force_depth
retract_z = tip_z + self.config.tip_pickup_retract_height
if not (self._validate_position(tip_x, tip_y, safe_z) and
self._validate_position(tip_x, tip_y, pickup_z) and
self._validate_position(tip_x, tip_y, retract_z)):
if not (
self._validate_position(tip_x, tip_y, safe_z)
and self._validate_position(tip_x, tip_y, pickup_z)
and self._validate_position(tip_x, tip_y, retract_z)
):
logger.error("枪头拾取位置超出安全范围")
return False
@@ -487,8 +497,7 @@ class LaiYuLiquidBackend:
safe_z = tip_z + self.config.tip_approach_height
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
move_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
tip_x, tip_y, safe_z
self.xyz_controller.move_to_work_coord, tip_x, tip_y, safe_z
)
if not move_success:
logger.error("移动到枪头上方失败")
@@ -498,22 +507,20 @@ class LaiYuLiquidBackend:
pickup_z = tip_z - self.config.tip_pickup_force_depth
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
z_down_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
tip_x, tip_y, pickup_z
self.xyz_controller.move_to_work_coord, tip_x, tip_y, pickup_z
)
if not z_down_success:
logger.error("Z轴下降到枪头位置失败")
return False
# 3. 等待一小段时间确保枪头牢固附着
await asyncio.sleep(0.2)
await self._ros_node.sleep(0.2)
# 4. Z轴上升到回退高度
retract_z = tip_z + self.config.tip_pickup_retract_height
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
z_up_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
tip_x, tip_y, retract_z
self.xyz_controller.move_to_work_coord, tip_x, tip_y, retract_z
)
if not z_up_success:
logger.error("Z轴上升失败")
@@ -533,7 +540,7 @@ class LaiYuLiquidBackend:
else:
# 模拟模式
logger.info("模拟模式:执行枪头拾取动作")
await asyncio.sleep(1.0) # 模拟整个拾取过程的时间
await self._ros_node.sleep(1.0) # 模拟整个拾取过程的时间
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
# 6. 标记枪头已附着
@@ -578,8 +585,10 @@ class LaiYuLiquidBackend:
safe_z = drop_z + self.config.safe_height
drop_height_z = drop_z + self.config.tip_drop_height
if not (self._validate_position(drop_x, drop_y, safe_z) and
self._validate_position(drop_x, drop_y, drop_height_z)):
if not (
self._validate_position(drop_x, drop_y, safe_z)
and self._validate_position(drop_x, drop_y, drop_height_z)
):
logger.error("枪头丢弃位置超出安全范围")
return False
@@ -592,8 +601,7 @@ class LaiYuLiquidBackend:
safe_z = drop_z + self.config.tip_drop_height
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
move_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
drop_x, drop_y, safe_z
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
)
if not move_success:
logger.error("移动到丢弃位置上方失败")
@@ -602,8 +610,7 @@ class LaiYuLiquidBackend:
# 2. Z轴下降到丢弃高度
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
z_down_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
drop_x, drop_y, drop_z
self.xyz_controller.move_to_work_coord, drop_x, drop_y, drop_z
)
if not z_down_success:
logger.error("Z轴下降到丢弃位置失败")
@@ -619,13 +626,12 @@ class LaiYuLiquidBackend:
logger.warning(f"枪头弹出命令失败: {e}")
# 4. 等待一小段时间确保枪头完全脱离
await asyncio.sleep(0.3)
await self._ros_node.sleep(0.3)
# 5. Z轴上升到安全高度
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
z_up_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
drop_x, drop_y, safe_z
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
)
if not z_up_success:
logger.error("Z轴上升失败")
@@ -645,7 +651,7 @@ class LaiYuLiquidBackend:
else:
# 模拟模式
logger.info("模拟模式:执行枪头丢弃动作")
await asyncio.sleep(0.8) # 模拟整个丢弃过程的时间
await self._ros_node.sleep(0.8) # 模拟整个丢弃过程的时间
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
# 7. 标记枪头已脱离,清空体积
@@ -671,7 +677,7 @@ class LaiYuLiquidBackend:
raise LaiYuLiquidError(f"体积超出范围: {volume}")
# 模拟吸取
await asyncio.sleep(0.3)
await self._ros_node.sleep(0.3)
self.current_volume += volume
logger.debug(f"{location} 吸取 {volume} μL")
return True
@@ -693,7 +699,7 @@ class LaiYuLiquidBackend:
raise LaiYuLiquidError(f"分配体积无效: {volume}")
# 模拟分配
await asyncio.sleep(0.3)
await self._ros_node.sleep(0.3)
self.current_volume -= volume
logger.debug(f"{location} 分配 {volume} μL")
return True
@@ -765,8 +771,9 @@ class LaiYuLiquid:
await self.backend.stop()
self.is_setup = False
async def transfer(self, source: str, target: str, volume: float,
tip_rack: str = "tip_rack_1", tip_position: int = 0) -> bool:
async def transfer(
self, source: str, target: str, volume: float, tip_rack: str = "tip_rack_1", tip_position: int = 0
) -> bool:
"""液体转移"""
try:
if not self.is_setup:
@@ -788,7 +795,7 @@ class LaiYuLiquid:
("吸取液体", self.backend.aspirate(volume, source)),
("移动到目标位置", self.backend.move_to(*target_pos)),
("分配液体", self.backend.dispense(volume, target)),
("丢弃吸头", self.backend.drop_tip())
("丢弃吸头", self.backend.drop_tip()),
]
for step_name, step_coro in steps:
@@ -823,7 +830,7 @@ class LaiYuLiquid:
"current_position": self.backend.current_position,
"tip_attached": self.backend.tip_attached,
"current_volume": self.backend.current_volume,
"resources": self.deck.list_resources()
"resources": self.deck.list_resources(),
}
@@ -846,7 +853,7 @@ def create_quick_setup() -> LaiYuLiquidDeck:
create_tip_rack_1000ul,
create_tip_rack_200ul,
create_96_well_plate,
create_waste_container
create_waste_container,
)
# 添加基本资源
@@ -877,5 +884,5 @@ __all__ = [
"LaiYuLiquidTipRack",
"LaiYuLiquidError",
"create_quick_setup",
"get_module_info"
"get_module_info",
]

View File

@@ -1,11 +1,11 @@
from __future__ import annotations
import re
import traceback
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast
from collections import Counter
import asyncio
import time
import pprint as pp
import traceback
from collections import Counter
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast
from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness
from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod
from pylabrobot.liquid_handling.standard import GripDirection
@@ -25,6 +25,8 @@ from pylabrobot.resources import (
Tip,
)
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):
@@ -536,6 +538,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
class LiquidHandlerAbstract(LiquidHandlerMiddleware):
"""Extended LiquidHandler with additional operations."""
support_touch_tip = True
_ros_node: BaseROS2DeviceNode
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8):
"""Initialize a LiquidHandler.
@@ -548,8 +551,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
self.group_info = dict()
super().__init__(backend, deck, simulator, channel_num)
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
@classmethod
def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]):
def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]):
"""Set the liquid in a well."""
for well, liquid_name, volume in zip(wells, liquid_names, volumes):
well.set_liquids([(liquid_name, volume)]) # type: ignore
@@ -1081,7 +1087,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
print(f"Waiting time: {msg}")
print(f"Current time: {time.strftime('%H:%M:%S')}")
print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}")
await asyncio.sleep(seconds)
await self._ros_node.sleep(seconds)
if msg:
print(f"Done: {msg}")
print(f"Current time: {time.strftime('%H:%M:%S')}")

View File

@@ -30,6 +30,7 @@ from pylabrobot.liquid_handling.standard import (
from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class PRCXIError(RuntimeError):
@@ -162,6 +163,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
)
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
def post_init(self, ros_node: BaseROS2DeviceNode):
super().post_init(ros_node)
self._unilabos_backend.post_init(ros_node)
def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]):
return super().set_liquid(wells, liquid_names, volumes)
@@ -424,6 +429,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
_num_channels = 8 # 默认通道数为 8
_is_reset_ok = False
_ros_node: BaseROS2DeviceNode
@property
def is_reset_ok(self) -> bool:
@@ -456,6 +462,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self._execute_setup = setup
self.debug = debug
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def create_protocol(self, protocol_name):
self.protocol_name = protocol_name
self.steps_todo_list = []
@@ -500,7 +509,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self.api_client.call("IAutomation", "Reset")
while not self.is_reset_ok:
print("Waiting for PRCXI9300 to reset...")
await asyncio.sleep(1)
await self._ros_node.sleep(1)
print("PRCXI9300 reset successfully.")
except ConnectionRefusedError as e:
raise RuntimeError(
@@ -533,7 +542,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8)
if len(set(tip_columns)) != 1:
raise ValueError("All pickups must be from the same tip column. Found different columns: " + str(tip_columns))
raise ValueError(
"All pickups must be from the same tip column. Found different columns: " + str(tip_columns)
)
PlateNo = plate_indexes[0] + 1
hole_col = tip_columns[0] + 1
hole_row = 1
@@ -1109,12 +1120,15 @@ class PRCXI9300Api:
"LiquidDispensingMethod": liquid_method,
}
class DefaultLayout:
def __init__(self, product_name: str = "PRCXI9300"):
self.labresource = {}
if product_name not in ["PRCXI9300", "PRCXI9320"]:
raise ValueError(f"Unsupported product_name: {product_name}. Only 'PRCXI9300' and 'PRCXI9320' are supported.")
raise ValueError(
f"Unsupported product_name: {product_name}. Only 'PRCXI9300' and 'PRCXI9320' are supported."
)
if product_name == "PRCXI9300":
self.rows = 2
@@ -1129,24 +1143,92 @@ class DefaultLayout:
self.layout = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
self.trash_slot = 16
self.waste_liquid_slot = 12
self.default_layout = {"MatrixId":f"{time.time()}","MatrixName":f"{time.time()}","MatrixCount":16,"WorkTablets":
[{"Number": 1, "Code": "T1", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 2, "Code": "T2", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 3, "Code": "T3", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 4, "Code": "T4", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 5, "Code": "T5", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 6, "Code": "T6", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 7, "Code": "T7", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 8, "Code": "T8", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 9, "Code": "T9", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 10, "Code": "T10", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 11, "Code": "T11", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 12, "Code": "T12", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}}, # 这个设置成废液槽,用储液槽表示
{"Number": 13, "Code": "T13", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 14, "Code": "T14", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 15, "Code": "T15", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 16, "Code": "T16", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}} # 这个设置成垃圾桶,用储液槽表示
]
self.default_layout = {
"MatrixId": f"{time.time()}",
"MatrixName": f"{time.time()}",
"MatrixCount": 16,
"WorkTablets": [
{
"Number": 1,
"Code": "T1",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 2,
"Code": "T2",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 3,
"Code": "T3",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 4,
"Code": "T4",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 5,
"Code": "T5",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 6,
"Code": "T6",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 7,
"Code": "T7",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 8,
"Code": "T8",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 9,
"Code": "T9",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 10,
"Code": "T10",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 11,
"Code": "T11",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 12,
"Code": "T12",
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0},
}, # 这个设置成废液槽,用储液槽表示
{
"Number": 13,
"Code": "T13",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 14,
"Code": "T14",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 15,
"Code": "T15",
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
},
{
"Number": 16,
"Code": "T16",
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0},
}, # 这个设置成垃圾桶,用储液槽表示
],
}
def get_layout(self) -> Dict[str, Any]:
@@ -1155,7 +1237,7 @@ class DefaultLayout:
"columns": self.columns,
"layout": self.layout,
"trash_slot": self.trash_slot,
"waste_liquid_slot": self.waste_liquid_slot
"waste_liquid_slot": self.waste_liquid_slot,
}
def get_trash_slot(self) -> int:
@@ -1181,14 +1263,16 @@ class DefaultLayout:
# 计算总需求
total_needed = sum(count for _, _, count in needs)
if total_needed > len(available_positions):
raise ValueError(f"需要 {total_needed} 个位置,但只有 {len(available_positions)} 个可用位置排除位置12和16")
raise ValueError(
f"需要 {total_needed} 个位置,但只有 {len(available_positions)} 个可用位置排除位置12和16"
)
# 依次分配位置
current_pos = 0
for reagent_name, material_name, count in needs:
material_uuid = self.labresource[material_name]['uuid']
material_enum = self.labresource[material_name]['materialEnum']
material_uuid = self.labresource[material_name]["uuid"]
material_enum = self.labresource[material_name]["materialEnum"]
for _ in range(count):
if current_pos >= len(available_positions):
@@ -1196,17 +1280,18 @@ class DefaultLayout:
position = available_positions[current_pos]
# 找到对应的tablet并更新
for tablet in self.default_layout['WorkTablets']:
if tablet['Number'] == position:
tablet['Material']['uuid'] = material_uuid
tablet['Material']['materialEnum'] = material_enum
layout_list.append(dict(reagent_name=reagent_name, material_name=material_name, positions=position))
for tablet in self.default_layout["WorkTablets"]:
if tablet["Number"] == position:
tablet["Material"]["uuid"] = material_uuid
tablet["Material"]["materialEnum"] = material_enum
layout_list.append(
dict(reagent_name=reagent_name, material_name=material_name, positions=position)
)
break
current_pos += 1
return self.default_layout, layout_list
if __name__ == "__main__":
# Example usage
# 1. 用导出的json给每个T1 T2板子设定相应的物料如果是孔板和枪头盒要对应区分
@@ -1302,9 +1387,6 @@ if __name__ == "__main__":
# # # plate2.set_well_liquids(plate_2_liquids)
# handler = PRCXI9300Handler(deck=deck, host="10.181.214.132", port=9999,
# timeout=10.0, setup=False, debug=False,
# simulator=True,
@@ -1391,11 +1473,8 @@ if __name__ == "__main__":
# # input("Press Enter to continue...") # Wait for user input before proceeding
# # print("PRCXI9300Handler initialized with deck and host settings.")
### 9320 ###
deck = PRCXI9300Deck(name="PRCXI_Deck", size_x=100, size_y=100, size_z=100)
from pylabrobot.resources.opentrons.tip_racks import tipone_96_tiprack_200ul, opentrons_96_tiprack_10ul
@@ -1415,9 +1494,12 @@ if __name__ == "__main__":
def get_tip_rack(name: str, child_prefix: str = "tip") -> PRCXI9300Container:
tip_racks = opentrons_96_tiprack_10ul(name).serialize()
tip_rack = PRCXI9300Container(
name=name, size_x=50, size_y=50, size_z=10, category="tip_rack", ordering=collections.OrderedDict({
k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()
})
name=name,
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict({k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()}),
)
tip_rack_serialized = tip_rack.serialize()
tip_rack_serialized["parent_name"] = deck.name
@@ -1629,6 +1711,7 @@ if __name__ == "__main__":
)
backend: PRCXI9300Backend = handler.backend
from pylabrobot.resources import set_volume_tracking
set_volume_tracking(enabled=True)
# res = backend.api_client.get_all_materials()
asyncio.run(handler.setup()) # Initialize the handler and setup the connection
@@ -1671,7 +1754,6 @@ if __name__ == "__main__":
# Initialize the backend and setup the connection
asyncio.run(handler.transfer_group("water", "master_mix", 10)) # Reset tip tracking
# asyncio.run(handler.pick_up_tips([plate8.children[8]],[0]))
# print(plate8.children[8])
# asyncio.run(handler.run_protocol())
@@ -1703,9 +1785,6 @@ if __name__ == "__main__":
# asyncio.run(handler.run_protocol()) # Run the protocol
# # # asyncio.run(handler.transfer_liquid(
# # # asp_vols=[10]*2,
# # # dis_vols=[10]*2,
@@ -1740,7 +1819,6 @@ if __name__ == "__main__":
# # # ], mix_time=3, mix_vol=50, height_to_bottom=0.5, offsets=Coordinate(0, 0, 0), mix_rate=100))
# # #print(json.dumps(handler._unilabos_backend.steps_todo_list, indent=2)) # Print matrix info
# # # asyncio.run(handler.remove_liquid(
# # # vols=[100]*16,
# # # sources=well_containers.children[-16:],
@@ -1775,31 +1853,32 @@ if __name__ == "__main__":
# # # input("Press Enter to continue...") # Wait for user input before proceeding
# # # print("PRCXI9300Handler initialized with deck and host settings.")
# 一些推荐版位组合的测试样例:
# 一些推荐版位组合的测试样例:
with open("prcxi_material.json", "r") as f:
material_info = json.load(f)
layout = DefaultLayout("PRCXI9320")
layout.add_lab_resource(material_info)
MatrixLayout_1, dict_1 = layout.recommend_layout([
MatrixLayout_1, dict_1 = layout.recommend_layout(
[
("reagent_1", "96 细胞培养皿", 3),
("reagent_2", "12道储液槽", 1),
("reagent_3", "200μL Tip头", 7),
("reagent_4", "10μL加长 Tip头", 1),
])
]
)
print(dict_1)
MatrixLayout_2, dict_2 = layout.recommend_layout([
MatrixLayout_2, dict_2 = layout.recommend_layout(
[
("reagent_1", "96深孔板", 4),
("reagent_2", "12道储液槽", 1),
("reagent_3", "200μL Tip头", 1),
("reagent_4", "10μL加长 Tip头", 1),
])
]
)
# with open("prcxi_material.json", "r") as f:
# material_info = json.load(f)

View File

@@ -8,6 +8,8 @@ import serial.tools.list_ports
from serial import Serial
from serial.serialutil import SerialException
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class RunzeSyringePumpMode(Enum):
Normal = 0
@@ -77,6 +79,8 @@ class RunzeSyringePumpInfo:
class RunzeSyringePumpAsync:
_ros_node: BaseROS2DeviceNode
def __init__(self, port: str, address: str = "1", volume: float = 25000, mode: RunzeSyringePumpMode = None):
self.port = port
self.address = address
@@ -102,6 +106,9 @@ class RunzeSyringePumpAsync:
self._run_future: Optional[Future[Any]] = None
self._run_lock = Lock()
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def _adjust_total_steps(self):
self.total_steps = 6000 if self.mode == RunzeSyringePumpMode.Normal else 48000
self.total_steps_vel = 48000 if self.mode == RunzeSyringePumpMode.AccuratePosVel else 6000
@@ -182,7 +189,7 @@ class RunzeSyringePumpAsync:
try:
await self._query(command)
while True:
await asyncio.sleep(0.5) # Wait for 0.5 seconds before polling again
await self._ros_node.sleep(0.5) # Wait for 0.5 seconds before polling again
status = await self.query_device_status()
if status == '`':
@@ -364,7 +371,7 @@ class RunzeSyringePumpAsync:
if self._read_task:
raise RunzeSyringePumpConnectionError
self._read_task = asyncio.create_task(self._read_loop())
self._read_task = self._ros_node.create_task(self._read_loop())
try:
await self.query_device_status()

View File

@@ -3,10 +3,14 @@ import logging
import time as time_module
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualCentrifuge:
"""Virtual centrifuge device - 简化版,只保留核心功能"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
# 处理可能的不同调用方式
if device_id is None and "id" in kwargs:
@@ -33,6 +37,9 @@ class VirtualCentrifuge:
if key not in skip_keys and not hasattr(self, key):
setattr(self, key, value)
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""Initialize virtual centrifuge"""
self.logger.info(f"Initializing virtual centrifuge {self.device_id}")
@@ -132,7 +139,7 @@ class VirtualCentrifuge:
break
# 每秒更新一次
await asyncio.sleep(1.0)
await self._ros_node.sleep(1.0)
# 离心完成
self.data.update({

View File

@@ -2,9 +2,13 @@ import asyncio
import logging
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualColumn:
"""Virtual column device for RunColumn protocol 🏛️"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
# 处理可能的不同调用方式
if device_id is None and 'id' in kwargs:
@@ -28,6 +32,9 @@ class VirtualColumn:
print(f"🏛️ === 虚拟色谱柱 {self.device_id} 已创建 === ✨")
print(f"📏 柱参数: 流速={self._max_flow_rate}mL/min | 长度={self._column_length}cm | 直径={self._column_diameter}cm 🔬")
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""Initialize virtual column 🚀"""
self.logger.info(f"🔧 初始化虚拟色谱柱 {self.device_id}")
@@ -101,7 +108,7 @@ class VirtualColumn:
step_time = separation_time / steps
for i in range(steps):
await asyncio.sleep(step_time)
await self._ros_node.sleep(step_time)
progress = (i + 1) / steps * 100
volume_processed = (i + 1) * 5.0 # 假设每步处理5mL

View File

@@ -4,16 +4,19 @@ import time as time_module
from typing import Dict, Any, Optional
from unilabos.compile.utils.vessel_parser import get_vessel
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualFilter:
"""Virtual filter device - 完全按照 Filter.action 规范 🌊"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
if device_id is None and 'id' in kwargs:
device_id = kwargs.pop('id')
if config is None and 'config' in kwargs:
config = kwargs.pop('config')
if device_id is None and "id" in kwargs:
device_id = kwargs.pop("id")
if config is None and "config" in kwargs:
config = kwargs.pop("config")
self.device_id = device_id or "unknown_filter"
self.config = config or {}
@@ -21,29 +24,34 @@ class VirtualFilter:
self.data = {}
# 从config或kwargs中获取配置参数
self.port = self.config.get('port') or kwargs.get('port', 'VIRTUAL')
self._max_temp = self.config.get('max_temp') or kwargs.get('max_temp', 100.0)
self._max_stir_speed = self.config.get('max_stir_speed') or kwargs.get('max_stir_speed', 1000.0)
self._max_volume = self.config.get('max_volume') or kwargs.get('max_volume', 500.0)
self.port = self.config.get("port") or kwargs.get("port", "VIRTUAL")
self._max_temp = self.config.get("max_temp") or kwargs.get("max_temp", 100.0)
self._max_stir_speed = self.config.get("max_stir_speed") or kwargs.get("max_stir_speed", 1000.0)
self._max_volume = self.config.get("max_volume") or kwargs.get("max_volume", 500.0)
# 处理其他kwargs参数
skip_keys = {'port', 'max_temp', 'max_stir_speed', 'max_volume'}
skip_keys = {"port", "max_temp", "max_stir_speed", "max_volume"}
for key, value in kwargs.items():
if key not in skip_keys and not hasattr(self, key):
setattr(self, key, value)
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""Initialize virtual filter 🚀"""
self.logger.info(f"🔧 初始化虚拟过滤器 {self.device_id}")
# 按照 Filter.action 的 feedback 字段初始化
self.data.update({
self.data.update(
{
"status": "Idle",
"progress": 0.0, # Filter.action feedback
"current_temp": 25.0, # Filter.action feedback
"filtered_volume": 0.0, # Filter.action feedback
"message": "Ready for filtration"
})
"message": "Ready for filtration",
}
)
self.logger.info(f"✅ 过滤器 {self.device_id} 初始化完成 🌊")
return True
@@ -52,9 +60,7 @@ class VirtualFilter:
"""Cleanup virtual filter 🧹"""
self.logger.info(f"🧹 清理虚拟过滤器 {self.device_id} 🔚")
self.data.update({
"status": "Offline"
})
self.data.update({"status": "Offline"})
self.logger.info(f"✅ 过滤器 {self.device_id} 清理完成 💤")
return True
@@ -67,7 +73,7 @@ class VirtualFilter:
stir_speed: float = 300.0,
temp: float = 25.0,
continue_heatchill: bool = False,
volume: float = 0.0
volume: float = 0.0,
) -> bool:
"""Execute filter action - 完全按照 Filter.action 参数 🌊"""
vessel_id, _ = get_vessel(vessel)
@@ -92,41 +98,34 @@ class VirtualFilter:
if temp > self._max_temp or temp < 4.0:
error_msg = f"🌡️ 温度 {temp}°C 超出范围 (4-{self._max_temp}°C) ⚠️"
self.logger.error(f"{error_msg}")
self.data.update({
"status": f"Error: 温度超出范围 ⚠️",
"message": error_msg
})
self.data.update({"status": f"Error: 温度超出范围 ⚠️", "message": error_msg})
return False
if stir and stir_speed > self._max_stir_speed:
error_msg = f"🌪️ 搅拌速度 {stir_speed} RPM 超出范围 (0-{self._max_stir_speed} RPM) ⚠️"
self.logger.error(f"{error_msg}")
self.data.update({
"status": f"Error: 搅拌速度超出范围 ⚠️",
"message": error_msg
})
self.data.update({"status": f"Error: 搅拌速度超出范围 ⚠️", "message": error_msg})
return False
if volume > self._max_volume:
error_msg = f"💧 过滤体积 {volume} mL 超出范围 (0-{self._max_volume} mL) ⚠️"
self.logger.error(f"{error_msg}")
self.data.update({
"status": f"Error",
"message": error_msg
})
self.data.update({"status": f"Error", "message": error_msg})
return False
# 开始过滤
filter_volume = volume if volume > 0 else 50.0
self.logger.info(f"🚀 开始过滤 {filter_volume}mL 液体 💧")
self.data.update({
self.data.update(
{
"status": f"Running",
"current_temp": temp,
"filtered_volume": 0.0,
"progress": 0.0,
"message": f"🚀 Starting filtration: {vessel_id}{filtrate_vessel_id}"
})
"message": f"🚀 Starting filtration: {vessel_id}{filtrate_vessel_id}",
}
)
try:
# 过滤过程 - 实时更新进度
@@ -157,13 +156,15 @@ class VirtualFilter:
status_msg += f" | 🌪️ 搅拌: {stir_speed} RPM"
status_msg += f" | 🌡️ {temp}°C | 📊 {progress:.1f}% | 💧 已过滤: {current_filtered:.1f}mL"
self.data.update({
self.data.update(
{
"progress": progress, # Filter.action feedback
"current_temp": temp, # Filter.action feedback
"filtered_volume": current_filtered, # Filter.action feedback
"status": "Running",
"message": f"🌊 Filtering: {progress:.1f}% complete, {current_filtered:.1f}mL filtered"
})
"message": f"🌊 Filtering: {progress:.1f}% complete, {current_filtered:.1f}mL filtered",
}
)
# 进度日志每25%打印一次)
if progress >= 25 and progress % 25 < 1:
@@ -172,7 +173,7 @@ class VirtualFilter:
if remaining <= 0:
break
await asyncio.sleep(1.0)
await self._ros_node.sleep(1.0)
# 过滤完成
final_temp = temp if continue_heatchill else 25.0
@@ -181,13 +182,15 @@ class VirtualFilter:
final_status += " | 🔥 继续加热搅拌"
self.logger.info(f"🔥 继续保持加热搅拌状态 🌪️")
self.data.update({
self.data.update(
{
"status": final_status,
"progress": 100.0, # Filter.action feedback
"current_temp": final_temp, # Filter.action feedback
"filtered_volume": filter_volume, # Filter.action feedback
"message": f"✅ Filtration completed: {filter_volume}mL filtered from {vessel_id}"
})
"message": f"✅ Filtration completed: {filter_volume}mL filtered from {vessel_id}",
}
)
self.logger.info(f"🎉 过滤完成! 💧 {filter_volume}mL 从 {vessel_id} 过滤到 {filtrate_vessel_id}")
self.logger.info(f"📊 最终状态: 温度 {final_temp}°C | 进度 100% | 体积 {filter_volume}mL 🏁")
@@ -196,10 +199,7 @@ class VirtualFilter:
except Exception as e:
error_msg = f"过滤过程中发生错误: {str(e)} 💥"
self.logger.error(f"{error_msg}")
self.data.update({
"status": f"Error",
"message": f"❌ Filtration failed: {str(e)}"
})
self.data.update({"status": f"Error", "message": f"❌ Filtration failed: {str(e)}"})
return False
# === 核心状态属性 - 按照 Filter.action feedback 字段 ===

View File

@@ -3,9 +3,13 @@ import logging
import time as time_module # 重命名time模块避免与参数冲突
from typing import Dict, Any
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualHeatChill:
"""Virtual heat chill device for HeatChillProtocol testing 🌡️"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
# 处理可能的不同调用方式
if device_id is None and 'id' in kwargs:
@@ -35,6 +39,9 @@ class VirtualHeatChill:
print(f"🌡️ === 虚拟温控设备 {self.device_id} 已创建 === ✨")
print(f"🔥 温度范围: {self._min_temp}°C ~ {self._max_temp}°C | 🌪️ 最大搅拌: {self._max_stir_speed} RPM")
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""Initialize virtual heat chill 🚀"""
self.logger.info(f"🔧 初始化虚拟温控设备 {self.device_id}")
@@ -177,7 +184,7 @@ class VirtualHeatChill:
break
# 等待1秒后再次检查
await asyncio.sleep(1.0)
await self._ros_node.sleep(1.0)
# 操作完成
final_stir_info = f" | 🌪️ 搅拌: {stir_speed} RPM" if stir else ""

View File

@@ -3,13 +3,19 @@ import logging
import time as time_module
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
def debug_print(message):
"""调试输出 🔍"""
print(f"🌪️ [ROTAVAP] {message}", flush=True)
class VirtualRotavap:
"""Virtual rotary evaporator device - 简化版,只保留核心功能 🌪️"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
# 处理可能的不同调用方式
if device_id is None and "id" in kwargs:
@@ -38,12 +44,16 @@ class VirtualRotavap:
print(f"🌪️ === 虚拟旋转蒸发仪 {self.device_id} 已创建 === ✨")
print(f"🔥 温度范围: 10°C ~ {self._max_temp}°C | 🌀 转速范围: 10 ~ {self._max_rotation_speed} RPM")
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""Initialize virtual rotary evaporator 🚀"""
self.logger.info(f"🔧 初始化虚拟旋转蒸发仪 {self.device_id}")
# 只保留核心状态
self.data.update({
self.data.update(
{
"status": "🏠 待机中",
"rotavap_state": "Ready", # Ready, Evaporating, Completed, Error
"current_temp": 25.0,
@@ -53,25 +63,30 @@ class VirtualRotavap:
"evaporated_volume": 0.0,
"progress": 0.0,
"remaining_time": 0.0,
"message": "🌪️ Ready for evaporation"
})
"message": "🌪️ Ready for evaporation",
}
)
self.logger.info(f"✅ 旋转蒸发仪 {self.device_id} 初始化完成 🌪️")
self.logger.info(f"📊 设备规格: 温度范围 10°C ~ {self._max_temp}°C | 转速范围 10 ~ {self._max_rotation_speed} RPM")
self.logger.info(
f"📊 设备规格: 温度范围 10°C ~ {self._max_temp}°C | 转速范围 10 ~ {self._max_rotation_speed} RPM"
)
return True
async def cleanup(self) -> bool:
"""Cleanup virtual rotary evaporator 🧹"""
self.logger.info(f"🧹 清理虚拟旋转蒸发仪 {self.device_id} 🔚")
self.data.update({
self.data.update(
{
"status": "💤 离线",
"rotavap_state": "Offline",
"current_temp": 25.0,
"rotation_speed": 0.0,
"vacuum_pressure": 1.0,
"message": "💤 System offline"
})
"message": "💤 System offline",
}
)
self.logger.info(f"✅ 旋转蒸发仪 {self.device_id} 清理完成 💤")
return True
@@ -84,7 +99,7 @@ class VirtualRotavap:
time: float = 180.0,
stir_speed: float = 100.0,
solvent: str = "",
**kwargs
**kwargs,
) -> bool:
"""Execute evaporate action - 简化版 🌪️"""
@@ -114,11 +129,11 @@ class VirtualRotavap:
self.logger.info(f"🧪 识别到溶剂: {solvent}")
# 根据溶剂调整参数
solvent_lower = solvent.lower()
if any(s in solvent_lower for s in ['water', 'aqueous']):
if any(s in solvent_lower for s in ["water", "aqueous"]):
temp = max(temp, 80.0)
pressure = max(pressure, 0.2)
self.logger.info(f"💧 水系溶剂:调整参数 → 温度 {temp}°C, 压力 {pressure} bar")
elif any(s in solvent_lower for s in ['ethanol', 'methanol', 'acetone']):
elif any(s in solvent_lower for s in ["ethanol", "methanol", "acetone"]):
temp = min(temp, 50.0)
pressure = min(pressure, 0.05)
self.logger.info(f"⚡ 易挥发溶剂:调整参数 → 温度 {temp}°C, 压力 {pressure} bar")
@@ -136,46 +151,53 @@ class VirtualRotavap:
if temp > self._max_temp or temp < 10.0:
error_msg = f"🌡️ 温度 {temp}°C 超出范围 (10-{self._max_temp}°C) ⚠️"
self.logger.error(f"{error_msg}")
self.data.update({
self.data.update(
{
"status": f"❌ 错误: 温度超出范围",
"rotavap_state": "Error",
"current_temp": 25.0,
"progress": 0.0,
"evaporated_volume": 0.0,
"message": error_msg
})
"message": error_msg,
}
)
return False
if stir_speed > self._max_rotation_speed or stir_speed < 10.0:
error_msg = f"🌀 旋转速度 {stir_speed} RPM 超出范围 (10-{self._max_rotation_speed} RPM) ⚠️"
self.logger.error(f"{error_msg}")
self.data.update({
self.data.update(
{
"status": f"❌ 错误: 转速超出范围",
"rotavap_state": "Error",
"current_temp": 25.0,
"progress": 0.0,
"evaporated_volume": 0.0,
"message": error_msg
})
"message": error_msg,
}
)
return False
if pressure < 0.01 or pressure > 1.0:
error_msg = f"💨 真空度 {pressure} bar 超出范围 (0.01-1.0 bar) ⚠️"
self.logger.error(f"{error_msg}")
self.data.update({
self.data.update(
{
"status": f"❌ 错误: 压力超出范围",
"rotavap_state": "Error",
"current_temp": 25.0,
"progress": 0.0,
"evaporated_volume": 0.0,
"message": error_msg
})
"message": error_msg,
}
)
return False
# 开始蒸发 - 🔧 现在time已经确保是float类型
self.logger.info(f"🚀 启动蒸发程序! 预计用时 {time/60:.1f}分钟 ⏱️")
self.data.update({
self.data.update(
{
"status": f"🌪️ 蒸发中: {actual_vessel}",
"rotavap_state": "Evaporating",
"current_temp": temp,
@@ -185,8 +207,9 @@ class VirtualRotavap:
"remaining_time": time,
"progress": 0.0,
"evaporated_volume": 0.0,
"message": f"🌪️ Evaporating {actual_vessel} at {temp}°C, {pressure} bar, {stir_speed} RPM"
})
"message": f"🌪️ Evaporating {actual_vessel} at {temp}°C, {pressure} bar, {stir_speed} RPM",
}
)
try:
# 蒸发过程 - 实时更新进度
@@ -201,9 +224,9 @@ class VirtualRotavap:
progress = min(100.0, (elapsed / total_time) * 100)
# 模拟蒸发体积 - 根据溶剂类型调整
if solvent and any(s in solvent.lower() for s in ['water', 'aqueous']):
if solvent and any(s in solvent.lower() for s in ["water", "aqueous"]):
evaporated_vol = progress * 0.6 # 水系溶剂蒸发慢
elif solvent and any(s in solvent.lower() for s in ['ethanol', 'methanol', 'acetone']):
elif solvent and any(s in solvent.lower() for s in ["ethanol", "methanol", "acetone"]):
evaporated_vol = progress * 1.0 # 易挥发溶剂蒸发快
else:
evaporated_vol = progress * 0.8 # 默认蒸发量
@@ -211,18 +234,22 @@ class VirtualRotavap:
# 🔧 更新状态 - 确保包含所有必需字段
status_msg = f"🌪️ 蒸发中: {actual_vessel} | 🌡️ {temp}°C | 💨 {pressure} bar | 🌀 {stir_speed} RPM | 📊 {progress:.1f}% | ⏰ 剩余: {remaining:.0f}s"
self.data.update({
self.data.update(
{
"remaining_time": remaining,
"progress": progress,
"evaporated_volume": evaporated_vol,
"current_temp": temp,
"status": status_msg,
"message": f"🌪️ Evaporating: {progress:.1f}% complete, 💧 {evaporated_vol:.1f}mL evaporated, ⏰ {remaining:.0f}s remaining"
})
"message": f"🌪️ Evaporating: {progress:.1f}% complete, 💧 {evaporated_vol:.1f}mL evaporated, ⏰ {remaining:.0f}s remaining",
}
)
# 进度日志每25%打印一次)
if progress >= 25 and int(progress) % 25 == 0 and int(progress) != last_logged_progress:
self.logger.info(f"📊 蒸发进度: {progress:.0f}% | 💧 已蒸发: {evaporated_vol:.1f}mL | ⏰ 剩余: {remaining:.0f}s ✨")
self.logger.info(
f"📊 蒸发进度: {progress:.0f}% | 💧 已蒸发: {evaporated_vol:.1f}mL | ⏰ 剩余: {remaining:.0f}s ✨"
)
last_logged_progress = int(progress)
# 时间到了,退出循环
@@ -230,17 +257,18 @@ class VirtualRotavap:
break
# 每秒更新一次
await asyncio.sleep(1.0)
await self._ros_node.sleep(1.0)
# 蒸发完成
if solvent and any(s in solvent.lower() for s in ['water', 'aqueous']):
if solvent and any(s in solvent.lower() for s in ["water", "aqueous"]):
final_evaporated = 60.0 # 水系溶剂
elif solvent and any(s in solvent.lower() for s in ['ethanol', 'methanol', 'acetone']):
elif solvent and any(s in solvent.lower() for s in ["ethanol", "methanol", "acetone"]):
final_evaporated = 100.0 # 易挥发溶剂
else:
final_evaporated = 80.0 # 默认
self.data.update({
self.data.update(
{
"status": f"✅ 蒸发完成: {actual_vessel} | 💧 蒸发量: {final_evaporated:.1f}mL",
"rotavap_state": "Completed",
"evaporated_volume": final_evaporated,
@@ -249,8 +277,9 @@ class VirtualRotavap:
"remaining_time": 0.0,
"rotation_speed": 0.0,
"vacuum_pressure": 1.0,
"message": f"✅ Evaporation completed: {final_evaporated}mL evaporated from {actual_vessel}"
})
"message": f"✅ Evaporation completed: {final_evaporated}mL evaporated from {actual_vessel}",
}
)
self.logger.info(f"🎉 蒸发操作完成! ✨")
self.logger.info(f"📊 蒸发结果:")
@@ -270,7 +299,8 @@ class VirtualRotavap:
error_msg = f"蒸发过程中发生错误: {str(e)} 💥"
self.logger.error(f"{error_msg}")
self.data.update({
self.data.update(
{
"status": f"❌ 蒸发错误: {str(e)}",
"rotavap_state": "Error",
"current_temp": 25.0,
@@ -278,8 +308,9 @@ class VirtualRotavap:
"evaporated_volume": 0.0,
"rotation_speed": 0.0,
"vacuum_pressure": 1.0,
"message": f"❌ Evaporation failed: {str(e)}"
})
"message": f"❌ Evaporation failed: {str(e)}",
}
)
return False
# === 核心状态属性 ===

View File

@@ -2,10 +2,14 @@ import asyncio
import logging
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualSeparator:
"""Virtual separator device for SeparateProtocol testing"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
# 处理可能的不同调用方式
if device_id is None and "id" in kwargs:
@@ -36,6 +40,9 @@ class VirtualSeparator:
if key not in skip_keys and not hasattr(self, key):
setattr(self, key, value)
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""Initialize virtual separator"""
print(f"=== VirtualSeparator {self.device_id} initialize() called! ===")
@@ -119,14 +126,14 @@ class VirtualSeparator:
for repeat in range(repeats):
# 搅拌阶段
for progress in range(0, 51, 10):
await asyncio.sleep(simulation_time / (repeats * 10))
await self._ros_node.sleep(simulation_time / (repeats * 10))
overall_progress = ((repeat * 100) + (progress * 0.5)) / repeats
self.data["progress"] = overall_progress
self.data["message"] = f"{repeat+1}次分离 - 搅拌中 ({progress}%)"
# 静置分相阶段
for progress in range(50, 101, 10):
await asyncio.sleep(simulation_time / (repeats * 10))
await self._ros_node.sleep(simulation_time / (repeats * 10))
overall_progress = ((repeat * 100) + (progress * 0.5)) / repeats
self.data["progress"] = overall_progress
self.data["message"] = f"{repeat+1}次分离 - 静置分相中 ({progress}%)"

View File

@@ -2,11 +2,16 @@ import time
import asyncio
from typing import Union
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualSolenoidValve:
"""
虚拟电磁阀门 - 简单的开关型阀门,只有开启和关闭两个状态
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
# 从配置中获取参数,提供默认值
if config is None:
@@ -22,6 +27,9 @@ class VirtualSolenoidValve:
self._valve_state = "Closed" # "Open" or "Closed"
self._is_open = False
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化设备"""
self._status = "Idle"
@@ -63,7 +71,7 @@ class VirtualSolenoidValve:
self._status = "Busy"
# 模拟阀门响应时间
await asyncio.sleep(self.response_time)
await self._ros_node.sleep(self.response_time)
# 处理不同的命令格式
if isinstance(command, str):

View File

@@ -3,6 +3,8 @@ import logging
import re
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualSolidDispenser:
"""
虚拟固体粉末加样器 - 用于处理 Add Protocol 中的固体试剂添加 ⚗️
@@ -13,6 +15,8 @@ class VirtualSolidDispenser:
- 简单反馈:成功/失败 + 消息 📊
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
self.device_id = device_id or "virtual_solid_dispenser"
self.config = config or {}
@@ -32,6 +36,9 @@ class VirtualSolidDispenser:
print(f"⚗️ === 虚拟固体分配器 {self.device_id} 创建成功! === ✨")
print(f"📊 设备规格: 最大容量 {self.max_capacity}g | 精度 {self.precision}g 🎯")
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化固体加样器 🚀"""
self.logger.info(f"🔧 初始化固体分配器 {self.device_id}")
@@ -263,7 +270,7 @@ class VirtualSolidDispenser:
for i in range(steps):
progress = (i + 1) / steps * 100
await asyncio.sleep(step_time)
await self._ros_node.sleep(step_time)
if i % 2 == 0: # 每隔一步显示进度
self.logger.debug(f"📊 加样进度: {progress:.0f}% | {amount_emoji} 正在分配 {reagent}...")

View File

@@ -3,9 +3,13 @@ import logging
import time as time_module
from typing import Dict, Any
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualStirrer:
"""Virtual stirrer device for StirProtocol testing - 功能完整版 🌪️"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
# 处理可能的不同调用方式
if device_id is None and 'id' in kwargs:
@@ -34,6 +38,9 @@ class VirtualStirrer:
print(f"🌪️ === 虚拟搅拌器 {self.device_id} 已创建 === ✨")
print(f"🔧 速度范围: {self._min_speed} ~ {self._max_speed} RPM | 📱 端口: {self.port}")
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""Initialize virtual stirrer 🚀"""
self.logger.info(f"🔧 初始化虚拟搅拌器 {self.device_id}")
@@ -134,7 +141,7 @@ class VirtualStirrer:
if remaining <= 0:
break
await asyncio.sleep(1.0)
await self._ros_node.sleep(1.0)
self.logger.info(f"✅ 搅拌阶段完成! 🌪️ {stir_speed} RPM × {stir_time}s")
@@ -176,7 +183,7 @@ class VirtualStirrer:
if remaining <= 0:
break
await asyncio.sleep(1.0)
await self._ros_node.sleep(1.0)
self.logger.info(f"✅ 沉降阶段完成! 🛑 静置 {settling_time}s")

View File

@@ -4,6 +4,8 @@ from enum import Enum
from typing import Union, Optional
import logging
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualPumpMode(Enum):
Normal = 0
@@ -14,6 +16,8 @@ class VirtualPumpMode(Enum):
class VirtualTransferPump:
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
"""
初始化虚拟转移泵
@@ -53,6 +57,9 @@ class VirtualTransferPump:
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s")
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化虚拟泵 🚀"""
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id}")
@@ -104,7 +111,7 @@ class VirtualTransferPump:
async def _simulate_operation(self, duration: float):
"""模拟操作延时 ⏱️"""
self._status = "Busy"
await asyncio.sleep(duration)
await self._ros_node.sleep(duration)
self._status = "Idle"
def _calculate_duration(self, volume: float, velocity: float = None) -> float:
@@ -223,7 +230,7 @@ class VirtualTransferPump:
# 等待一小步时间
if i < steps and step_duration > 0:
await asyncio.sleep(step_duration)
await self._ros_node.sleep(step_duration)
else:
# 移动距离很小,直接完成
self._position = target_position
@@ -341,7 +348,7 @@ class VirtualTransferPump:
# 短暂停顿
self.logger.debug("⏸️ 短暂停顿...")
await asyncio.sleep(0.1)
await self._ros_node.sleep(0.1)
# 排液
await self.dispense(volume, dispense_velocity)

View File

@@ -3,6 +3,7 @@ from cgi import print_arguments
from doctest import debug
from typing import Dict, Any, List, Optional
import requests
from pylabrobot.resources.resource import Resource as ResourcePLR
from pathlib import Path
import pandas as pd
import time
@@ -11,16 +12,15 @@ import re
import threading
import json
from urllib3 import response
from unilabos.devices.workstation.workstation_base import WorkstationBase
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation, BioyondResourceSynchronizer
from unilabos.devices.workstation.bioyond_studio.config import (
API_CONFIG, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, SOLID_LIQUID_MAPPINGS
)
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck
from unilabos.utils.log import logger
from unilabos.registry.registry import lab_registry
def _iso_local_now_ms() -> str:
# 文档要求:到毫秒 + Z例如 2025-08-15T05:43:22.814Z
dt = datetime.now()
@@ -36,29 +36,25 @@ class BioyondCellWorkstation(BioyondWorkstation):
查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28)
"""
def __init__(
self,
bioyond_config: Optional[Dict[str, Any]] = None,
station_resource: Optional[Dict[str, Any]] = None,
*args, **kwargs,
):
def __init__(self, config: dict = None, deck=None, protocol_type=None, **kwargs):
# 使用统一配置,支持自定义覆盖, 从 config.py 加载完整配置
self.bioyond_config = bioyond_config or {
self.bioyond_config ={
**API_CONFIG,
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
"warehouse_mapping": WAREHOUSE_MAPPING
"warehouse_mapping": WAREHOUSE_MAPPING,
"debug_mode": False
}
# "material_type_mappings": MATERIAL_TYPE_MAPPINGS
# "warehouse_mapping": WAREHOUSE_MAPPING
print(self.bioyond_config)
if deck is None and config:
deck = config.get('deck')
# print(self.bioyond_config)
self.debug_mode = self.bioyond_config["debug_mode"]
self.http_service_started = self.debug_mode
deck = kwargs.pop("deck", None)
self.device_id = kwargs.pop("device_id", "bioyond_cell_workstation")
super().__init__(bioyond_config=self.bioyond_config, deck=deck, station_resource=station_resource, *args, **kwargs)
self._device_id = "bioyond_cell_workstation" # 默认值后续会从_ros_node获取
super().__init__(bioyond_config=config, deck=deck)
self.update_push_ip() #直接修改奔耀端的报送ip地址
logger.info("已更新奔耀端推送 IP 地址")
@@ -72,6 +68,13 @@ class BioyondCellWorkstation(BioyondWorkstation):
self.last_order_code = None
logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})")
@property
def device_id(self):
"""获取设备ID优先从_ros_node获取否则返回默认值"""
if hasattr(self, '_ros_node') and self._ros_node is not None:
return getattr(self._ros_node, 'device_id', self._device_id)
return self._device_id
def _start_http_service(self):
"""启动 HTTP 服务"""
host = self.bioyond_config.get("HTTP_host", "")
@@ -253,7 +256,7 @@ class BioyondCellWorkstation(BioyondWorkstation):
def auto_feeding4to3(
self,
# ★ 修改点:默认模板路径
xlsx_path: Optional[str] = "C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx",
xlsx_path: Optional[str] = "/Users/sml/work/Unilab/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx",
# ---------------- WH4 - 加样头面 (Z=1, 12个点位) ----------------
WH4_x1_y1_z1_1_materialName: str = "", WH4_x1_y1_z1_1_quantity: float = 0.0,
WH4_x2_y1_z1_2_materialName: str = "", WH4_x2_y1_z1_2_quantity: float = 0.0,
@@ -305,7 +308,7 @@ class BioyondCellWorkstation(BioyondWorkstation):
# ---------- 模式 1: Excel 导入 ----------
if xlsx_path:
path = Path(xlsx_path)
path = Path(__file__).parent / Path(xlsx_path)
if path.exists(): # ★ 修改点:路径存在才加载
try:
df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl")
@@ -470,14 +473,23 @@ class BioyondCellWorkstation(BioyondWorkstation):
- totalMass 自动计算为所有物料质量之和
- createTime 缺失或为空时自动填充为当前日期YYYY/M/D
"""
path = Path(xlsx_path)
default_path = Path("/Users/sml/work/Unilab/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/2025092701.xlsx")
path = Path(xlsx_path) if xlsx_path else default_path
print(f"[create_orders] 使用 Excel 路径: {path}")
if path != default_path:
print("[create_orders] 来源: 调用方传入自定义路径")
else:
print("[create_orders] 来源: 使用默认模板路径")
if not path.exists():
print(f"[create_orders] ⚠️ Excel 文件不存在: {path}")
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
try:
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
except Exception as e:
raise RuntimeError(f"读取 Excel 失败:{e}")
print(f"[create_orders] Excel 读取成功,行数: {len(df)}, 列: {list(df.columns)}")
# 列名容错:返回可选列名,找不到则返回 None
def _pick(col_names: List[str]) -> Optional[str]:
@@ -494,9 +506,20 @@ class BioyondCellWorkstation(BioyondWorkstation):
col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"])
col_cond = _pick(["电导测试分液体积", "conductivityInfo"])
col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"])
print("[create_orders] 列匹配结果:", {
"order_name": col_order_name,
"create_time": col_create_time,
"bottle_type": col_bottle_type,
"mix_time": col_mix_time,
"load": col_load,
"pouch": col_pouch,
"conductivity": col_cond,
"conductivity_bottle_count": col_cond_cnt,
})
# 物料列:所有以 (g) 结尾
material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")]
print(f"[create_orders] 识别到的物料列: {material_cols}")
if not material_cols:
raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。")
@@ -544,6 +567,9 @@ class BioyondCellWorkstation(BioyondWorkstation):
if mass > 0:
mats.append({"name": mcol.replace("(g)", ""), "mass": mass})
total_mass += mass
else:
if mass < 0:
print(f"[create_orders] 第 {idx+1} 行物料 {mcol} 数值为负数: {mass}")
order_data = {
"batchId": batch_id,
@@ -558,11 +584,23 @@ class BioyondCellWorkstation(BioyondWorkstation):
"materialInfos": mats,
"totalMass": round(total_mass, 4) # 自动汇总
}
print(f"[create_orders] 第 {idx+1} 行解析结果: orderName={order_data['orderName']}, "
f"loadShedding={order_data['loadSheddingInfo']}, pouchCell={order_data['pouchCellInfo']}, "
f"conductivity={order_data['conductivityInfo']}, totalMass={order_data['totalMass']}, "
f"material_count={len(mats)}")
if order_data["totalMass"] <= 0:
print(f"[create_orders] ⚠️ 第 {idx+1} 行总质量 <= 0可能导致 LIMS 校验失败")
if not mats:
print(f"[create_orders] ⚠️ 第 {idx+1} 行未找到有效物料")
orders.append(order_data)
print("================================================")
print("orders:", orders)
print(f"[create_orders] 即将提交订单数量: {len(orders)}")
response = self._post_lims("/api/lims/order/orders", orders)
print(response)
print(f"[create_orders] 接口返回: {response}")
# 等待任务报送成功
data_list = response.get("data", [])
if data_list:
@@ -1013,24 +1051,71 @@ class BioyondCellWorkstation(BioyondWorkstation):
"create_result": create_result,
"inbound_result": inbound_result,
}
def resource_tree_transfer(self, old_parent: ResourcePLR, plr_resource: ResourcePLR, parent_resource: ResourcePLR):
# ROS2DeviceNode.run_async_func(self._ros_node.resource_tree_transfer, True, **{
# "old_parent": old_parent,
# "plr_resource": plr_resource,
# "parent_resource": parent_resource,
# })
print("resource_tree_transfer", plr_resource, parent_resource)
if hasattr(plr_resource, "unilabos_extra") and plr_resource.unilabos_extra:
if "update_resource_site" in plr_resource.unilabos_extra:
site = plr_resource.unilabos_extra["update_resource_site"]
plr_model = plr_resource.model
board_type = None
for key, (moudle_name,moudle_uuid) in MATERIAL_TYPE_MAPPINGS.items():
if plr_model == moudle_name:
board_type = key
break
if board_type is None:
pass
bottle1 = plr_resource.children[0]
bottle_moudle = bottle1.model
bottle_type = None
for key, (moudle_name, moudle_uuid) in MATERIAL_TYPE_MAPPINGS.items():
if bottle_moudle == moudle_name:
bottle_type = key
break
# 从 parent_resource 获取仓库名称
warehouse_name = parent_resource.name if parent_resource else "手动堆栈"
logger.info(f"拖拽上料: {plr_resource.name} -> {warehouse_name} / {site}")
self.create_sample(plr_resource.name, board_type, bottle_type, site, warehouse_name)
return
self.lab_logger().warning(f"无库位的上料,不处理,{plr_resource} 挂载到 {parent_resource}")
def create_sample(
self,
name: str,
board_type: str,
bottle_type: str,
location_code: str
location_code: str,
warehouse_name: str = "手动堆栈"
) -> Dict[str, Any]:
"""创建配液板物料并自动入库。
Args:
material_name: 物料名称,支持 "5ml分液瓶板"/"5ml分液瓶""配液瓶(小)板"/"配液瓶(小)"
quantity: 主物料与明细的数量,默认 1。
location_code: 库位编号,例如 "A01",将自动映射为 "手动堆栈" 下的 UUID。
name: 物料名称
board_type: 板类型,如 "5ml分液瓶板""配液瓶(小)板"
bottle_type: 瓶类型,如 "5ml分液瓶""配液瓶(小)"
location_code: 库位编号,例如 "A01"
warehouse_name: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左""自动堆栈-右"
"""
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
bottle_type_id = MATERIAL_TYPE_MAPPINGS[bottle_type][1]
location_id = WAREHOUSE_MAPPING["手动堆栈"]["site_uuids"][location_code]
# 从指定仓库获取库位UUID
if warehouse_name not in WAREHOUSE_MAPPING:
logger.error(f"未找到仓库: {warehouse_name},回退到手动堆栈")
warehouse_name = "手动堆栈"
if location_code not in WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]:
logger.error(f"仓库 {warehouse_name} 中未找到库位 {location_code}")
raise ValueError(f"库位 {location_code} 在仓库 {warehouse_name} 中不存在")
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
logger.info(f"创建样品入库: {name} -> {warehouse_name}/{location_code} (UUID: {location_id})")
# 新建小瓶
details = []
@@ -1073,19 +1158,14 @@ class BioyondCellWorkstation(BioyondWorkstation):
if __name__ == "__main__":
lab_registry.setup()
ws = BioyondCellWorkstation()
ws.create_sample(name="test", board_type="配液瓶(小)板", bottle_type="配液瓶(小)", location_code="B01")
deck = BIOYOND_YB_Deck(setup=True)
ws = BioyondCellWorkstation(deck=deck)
# ws.create_sample(name="test", board_type="配液瓶(小)板", bottle_type="配液瓶(小)", location_code="B01")
# logger.info(ws.scheduler_stop())
# logger.info(ws.scheduler_start())
# results = ws.create_materials(SOLID_LIQUID_MAPPINGS)
# for r in results:
# logger.info(r)
# 从CSV文件读取物料列表并批量创建入库
# result = ws.create_and_inbound_materials()
# 继续后续流程
logger.info(ws.auto_feeding4to3()) #搬运物料到3号箱
# logger.info(ws.auto_feeding4to3()) #搬运物料到3号箱
# # # 使用正斜杠或 Path 对象来指定文件路径
# excel_path = Path("unilabos\\devices\\workstation\\bioyond_studio\\bioyond_cell\\2025092701.xlsx")
# logger.info(ws.create_orders(excel_path))

View File

@@ -1,715 +0,0 @@
# -*- coding: utf-8 -*-
from typing import Dict, Any, List, Optional
from datetime import datetime, timezone
import requests
from pathlib import Path
import pandas as pd
import time
from datetime import datetime, timezone, timedelta
import re
import threading
from unilabos.devices.workstation.workstation_base import WorkstationBase
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
from unilabos.utils.log import logger
from pylabrobot.resources.deck import Deck
def _iso_utc_now_ms() -> str:
# 文档要求:到毫秒 + Z例如 2025-08-15T05:43:22.814Z
dt = datetime.now(timezone.utc)
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond/1000):03d}Z"
class BioyondWorkstation(WorkstationBase):
"""
集成 Bioyond LIMS 的工作站示例,
覆盖:入库(2.17/2.18) → 新建实验(2.14) → 启动调度(2.7) →
运行中推送:物料变更(2.24)、步骤完成(2.21)、订单完成(2.23) →
查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28)
"""
def __init__(
self,
bioyond_config: Optional[Dict[str, Any]] = None,
station_resource: Optional[Dict[str, Any]] = None,
debug_mode: bool = False, # 增加调试模式开关
*args, **kwargs,
):
self.bioyond_config = bioyond_config or {
"base_url": "http://192.168.1.200:44386",
"api_key": "8A819E5C",
"timeout": 30,
"report_token": "CHANGE_ME_TOKEN"
}
self.http_service_started = False
self.debug_mode = debug_mode
super().__init__(deck=Deck, station_resource=station_resource, *args, **kwargs)
logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})")
# 实例化并在后台线程启动 HTTP 报送服务
self.order_status = {}
try:
t = threading.Thread(target=self._start_http_service_bg, daemon=True, name="unilab_http")
t.start()
except Exception as e:
logger.error(f"unilab-server后台启动报送服务失败: {e}")
@property
def device_id(self) -> str:
try:
return getattr(self, "_ros_node").device_id # 兼容 ROS 场景
except Exception:
return "bioyond_workstation"
def _start_http_service_bg(self, host: str = "192.168.1.104", port: int = 8080) -> None:
logger.info("进入 _start_http_service_bg 函数")
try:
self.service = WorkstationHTTPService(self, host=host, port=port)
logger.info("WorkstationHTTPService 实例化完成")
self.service.start()
self.http_service_started = True
logger.info(f"unilab_HTTP 服务成功启动: {host}:{port}")
#一直挂着,直到进程退出
while True:
time.sleep(1)
except Exception as e:
self.http_service_started = False
logger.error(f"启动unilab_HTTP服务失败: {e}", exc_info=True)
# -------------------- 基础HTTP封装 --------------------
def _url(self, path: str) -> str:
return f"{self.bioyond_config['base_url'].rstrip('/')}/{path.lstrip('/')}"
def _post_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]:
"""LIMS API大多数接口用 {apiKey/requestTime,data} 包装"""
payload = {
"apiKey": self.bioyond_config["api_key"],
"requestTime": _iso_utc_now_ms()
}
if data is not None:
payload["data"] = data
if self.debug_mode:
# 模拟返回,不发真实请求
logger.info(f"[DEBUG] POST {path} with payload={payload}")
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
try:
r = requests.post(
self._url(path),
json=payload,
timeout=self.bioyond_config.get("timeout", 30),
headers={"Content-Type": "application/json"}
)
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"POST {path} 失败: {e}")
return {"error": str(e)}
# --- 修正_post_report / _post_report_raw 同样走 debug_mode ---
def _post_report(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
payload = {
"token": self.bioyond_config.get("report_token", ""),
"request_time": _iso_utc_now_ms(),
"data": data
}
if self.debug_mode:
logger.info(f"[DEBUG] POST {path} with payload={payload}")
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
try:
r = requests.post(self._url(path), json=payload,
timeout=self.bioyond_config.get("timeout", 30),
headers={"Content-Type": "application/json"})
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"POST {path} 失败: {e}")
return {"error": str(e)}
def _post_report_raw(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
if self.debug_mode:
logger.info(f"[DEBUG] POST {path} with body={body}")
return {"debug": True, "url": self._url(path), "payload": body, "status": "ok"}
try:
r = requests.post(self._url(path), json=body,
timeout=self.bioyond_config.get("timeout", 30),
headers={"Content-Type": "application/json"})
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"POST {path} 失败: {e}")
return {"error": str(e)}
# -------------------- 单点接口封装 --------------------
# 2.17 入库物料(单个)
def storage_inbound(self, material_id: str, location_id: str) -> Dict[str, Any]:
return self._post_lims("/api/lims/storage/inbound", {
"materialId": material_id,
"locationId": location_id
})
# 2.18 批量入库(多个)
def storage_batch_inbound(self, items: List[Dict[str, str]]) -> Dict[str, Any]:
"""
items = [{"materialId": "...", "locationId": "..."}, ...]
"""
return self._post_lims("/api/lims/storage/batch-inbound", items)
# 3.30 自动化上料Excel -> JSON -> POST /api/lims/order/auto-feeding4to3
def auto_feeding4to3_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
"""
根据固定模板解析 Excel
- 四号手套箱加样头面 (2-13行, 3-7列)
- 四号手套箱原液瓶面 (15-23行, 3-9列)
- 三号手套箱人工堆栈 (26-40行, 3-7列)
"""
path = Path(xlsx_path)
if not path.exists():
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
try:
df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl")
except Exception as e:
raise RuntimeError(f"读取 Excel 失败:{e}")
items: List[Dict[str, Any]] = []
# 四号手套箱 - 加样头面2-13行, 3-7列
for _, row in df.iloc[1:13, 2:7].iterrows():
item = {
"sourceWHName": "四号手套箱堆栈",
"posX": int(row[2]),
"posY": int(row[3]),
"posZ": int(row[4]),
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
}
if item["materialName"]:
items.append(item)
# 四号手套箱 - 原液瓶面15-23行, 3-9列
for _, row in df.iloc[14:23, 2:9].iterrows():
item = {
"sourceWHName": "四号手套箱堆栈",
"posX": int(row[2]),
"posY": int(row[3]),
"posZ": int(row[4]),
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
"materialType": str(row[7]).strip() if pd.notna(row[7]) else "",
"targetWH": str(row[8]).strip() if pd.notna(row[8]) else "",
}
if item["materialName"]:
items.append(item)
# 三号手套箱人工堆栈26-40行, 3-7列
for _, row in df.iloc[25:40, 2:7].iterrows():
item = {
"sourceWHName": "三号手套箱人工堆栈",
"posX": int(row[2]),
"posY": int(row[3]),
"posZ": int(row[4]),
"materialType": str(row[5]).strip() if pd.notna(row[5]) else "",
"materialId": str(row[6]).strip() if pd.notna(row[6]) else "",
"quantity": 1 # 默认数量1
}
if item["materialId"] or item["materialType"]:
items.append(item)
return self._post_lims("/api/lims/order/auto-feeding4to3", items)
def auto_batch_outbound_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
"""
3.31 自动化下料Excel -> JSON -> POST /api/lims/storage/auto-batch-out-bound
"""
path = Path(xlsx_path)
if not path.exists():
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
try:
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
except Exception as e:
raise RuntimeError(f"读取 Excel 失败:{e}")
def pick(names: List[str]) -> Optional[str]:
for n in names:
if n in df.columns:
return n
return None
c_loc = pick(["locationId", "库位ID", "库位Id", "库位id"])
c_wh = pick(["warehouseId", "仓库ID", "仓库Id", "仓库id"])
c_qty = pick(["数量", "quantity"])
c_x = pick(["x", "X", "posX", "坐标X"])
c_y = pick(["y", "Y", "posY", "坐标Y"])
c_z = pick(["z", "Z", "posZ", "坐标Z"])
required = [c_loc, c_wh, c_qty, c_x, c_y, c_z]
if any(c is None for c in required):
raise KeyError("Excel 缺少必要列locationId/warehouseId/数量/x/y/z支持多别名至少要能匹配到")
def as_int(v, d=0):
try:
if pd.isna(v): return d
return int(v)
except Exception:
try:
return int(float(v))
except Exception:
return d
def as_float(v, d=0.0):
try:
if pd.isna(v): return d
return float(v)
except Exception:
return d
def as_str(v, d=""):
if v is None or (isinstance(v, float) and pd.isna(v)): return d
s = str(v).strip()
return s if s else d
items: List[Dict[str, Any]] = []
for _, row in df.iterrows():
items.append({
"locationId": as_str(row[c_loc]),
"warehouseId": as_str(row[c_wh]),
"quantity": as_float(row[c_qty]),
"x": as_int(row[c_x]),
"y": as_int(row[c_y]),
"z": as_int(row[c_z]),
})
return self._post_lims("/api/lims/storage/auto-batch-out-bound", items)
# 2.14 新建实验
def create_orders(self, xlsx_path: str) -> Dict[str, Any]:
"""
从 Excel 解析并创建实验2.14
约定:
- batchId = Excel 文件名(不含扩展名)
- 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列)
- totalMass 自动计算为所有物料质量之和
- createTime 缺失或为空时自动填充为当前日期YYYY/M/D
"""
path = Path(xlsx_path)
if not path.exists():
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
try:
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
except Exception as e:
raise RuntimeError(f"读取 Excel 失败:{e}")
# 列名容错:返回可选列名,找不到则返回 None
def _pick(col_names: List[str]) -> Optional[str]:
for c in col_names:
if c in df.columns:
return c
return None
col_order_name = _pick(["配方ID", "orderName", "订单编号"])
col_create_time = _pick(["创建日期", "createTime"])
col_bottle_type = _pick(["配液瓶类型", "bottleType"])
col_mix_time = _pick(["混匀时间(s)", "mixTime"])
col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"])
col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"])
col_cond = _pick(["电导测试分液体积", "conductivityInfo"])
col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"])
# 物料列:所有以 (g) 结尾
material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")]
if not material_cols:
raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。")
batch_id = path.stem
def _to_ymd_slash(v) -> str:
# 统一为 "YYYY/M/D";为空或解析失败则用当前日期
if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "":
ts = datetime.now()
else:
try:
ts = pd.to_datetime(v)
except Exception:
ts = datetime.now()
return f"{ts.year}/{ts.month}/{ts.day}"
def _as_int(val, default=0) -> int:
try:
if pd.isna(val):
return default
return int(val)
except Exception:
return default
def _as_str(val, default="") -> str:
if val is None or (isinstance(val, float) and pd.isna(val)):
return default
s = str(val).strip()
return s if s else default
orders: List[Dict[str, Any]] = []
for idx, row in df.iterrows():
mats: List[Dict[str, Any]] = []
total_mass = 0.0
for mcol in material_cols:
val = row.get(mcol, None)
if val is None or (isinstance(val, float) and pd.isna(val)):
continue
try:
mass = float(val)
except Exception:
continue
if mass > 0:
mats.append({"name": mcol.replace("(g)", ""), "mass": mass})
total_mass += mass
order_data = {
"batchId": batch_id,
"orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}",
"createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None),
"bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶",
"mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0,
"loadSheddingInfo": _as_int(row[col_load]) if col_load else 0,
"pouchCellInfo": _as_int(row[col_pouch]) if col_pouch else 0,
"conductivityInfo": _as_int(row[col_cond]) if col_cond else 0,
"conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0,
"materialInfos": mats,
"totalMass": round(total_mass, 4) # 自动汇总
}
orders.append(order_data)
# print(orders)
response = self._post_lims("/api/lims/order/orders", orders)
self.order_status[response["data"]["orderCode"]] = "running"
while True:
time.sleep(5)
if self.order_status.get(response["data"]["orderCode"], None) == "finished":
logger.info(f"配液实验已完成 ,即将执行 3-2-1 转运")
break
logger.info(f"等待配液实验完成")
self.transfer_3_to_2_to_1()
r321 = self.wait_for_transfer_task()
logger.info(f"3-2-1 转运完成,返回结果")
return r321
# 2.7 启动调度
def scheduler_start(self) -> Dict[str, Any]:
return self._post_lims("/api/lims/scheduler/start")
# 3.10 停止调度
def scheduler_stop(self) -> Dict[str, Any]:
"""
停止调度 (3.10)
请求体只包含 apiKey 和 requestTime
"""
return self._post_lims("/api/lims/scheduler/stop")
# 2.9 继续调度
def scheduler_continue(self) -> Dict[str, Any]:
"""
继续调度 (2.9)
请求体只包含 apiKey 和 requestTime
"""
return self._post_lims("/api/lims/scheduler/continue")
# 2.24 物料变更推送
def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]:
"""
material_obj 按 2.24 的裸对象格式(包含 id/typeName/locations/detail 等)
"""
return self._post_report_raw("/report/material_change", material_obj)
# 2.21 步骤完成推送BS → LIMS
def report_step_finish(self,
order_code: str,
order_name: str,
step_name: str,
step_id: str,
sample_id: str,
start_time: str,
end_time: str,
execution_status: str = "completed") -> Dict[str, Any]:
data = {
"orderCode": order_code,
"orderName": order_name,
"stepName": step_name,
"stepId": step_id,
"sampleId": sample_id,
"startTime": start_time,
"endTime": end_time,
"executionStatus": execution_status
}
return self._post_report("/report/step_finish", data)
# 2.23 订单完成推送BS → LIMS
def report_order_finish(self,
order_code: str,
order_name: str,
start_time: str,
end_time: str,
status: str = "30", # 30 完成 / -11 异常停止 / -12 人工停止
workflow_status: str = "Finished",
completion_time: Optional[str] = None,
used_materials: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
data = {
"orderCode": order_code,
"orderName": order_name,
"startTime": start_time,
"endTime": end_time,
"status": status,
"workflowStatus": workflow_status,
"completionTime": completion_time or end_time,
"usedMaterials": used_materials or []
}
return self._post_report("/report/order_finish", data)
# 2.5 批量查询实验报告(用于轮询是否完成)
def order_list(self,
status: Optional[str] = None,
begin_time: Optional[str] = None,
end_time: Optional[str] = None,
filter_text: Optional[str] = None,
skip: int = 0, page: int = 10) -> Dict[str, Any]:
data: Dict[str, Any] = {"skipCount": skip, "pageCount": page}
if status is not None: # 80 成功 / 90 失败 / 100 执行中
data["status"] = status
if begin_time:
data["timeType"] = "CreationTime"
data["beginTime"] = begin_time
if end_time:
data["endTime"] = end_time
if filter_text:
data["filter"] = filter_text
return self._post_lims("/api/lims/order/order-list", data)
# 2.6 实验报告查询根据任务ID拿详情
def order_report(self, order_id: str) -> Dict[str, Any]:
return self._post_lims("/api/lims/order/order-report", order_id)
# 2.32 3-2-1 物料转运
def transfer_3_to_2_to_1(self,
# source_wh_id: Optional[str] = None,
source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b',
source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z
}
if source_wh_id:
payload["sourceWHID"] = source_wh_id
return self._post_lims("/api/lims/order/transfer-task3To2To1", payload)
# 2.28 样品/废料取出
def take_out(self,
order_id: str,
preintake_ids: Optional[List[str]] = None,
material_ids: Optional[List[str]] = None) -> Dict[str, Any]:
data = {
"orderId": order_id,
"preintakeIds": preintake_ids or [],
"materialIds": material_ids or []
}
return self._post_lims("/api/lims/order/take-out", data)
# --------可选占位方法文档未定义的“1号站内部流程 / 1-2转运”--------
def start_station1_internal_flow(self, **kwargs) -> None:
logger.info("启动1号站内部流程占位按现场系统填充具体指令")
# 3.x 1→2 物料转运
def transfer_1_to_2(self) -> Dict[str, Any]:
"""
1→2 物料转运
URL: /api/lims/order/transfer-task1To2
只需要 apiKey 和 requestTime
"""
return self._post_lims("/api/lims/order/transfer-task1To2")
# -------------------- 整体编排 --------------------
def run_full_workflow(self,
inbound_items: List[Dict[str, str]],
orders: List[Dict[str, Any]],
poll_filter_code: Optional[str] = None,
poll_timeout_s: int = 600,
poll_interval_s: int = 5,
transfer_source: Optional[Dict[str, Any]] = None,
takeout_order_id: Optional[str] = None) -> None:
"""
一键串联:
1) 入库 3-4 个物料 → 2) 新建实验 → 3) 启动调度
运行中如需4) 物料变更推送 5) 步骤完成推送 6) 订单完成推送
完成后查询实验2.5/2.6)→ 7) 3-2-1 转运 → 8) 1号站内部流程
→ 9) 1-2 转运 → 10) 样品/废料取出
"""
# 1. 入库多于1个就用批量接口 2.18
if len(inbound_items) == 1:
r = self.storage_inbound(inbound_items[0]["materialId"], inbound_items[0]["locationId"])
logger.info(f"单个入库结果: {r}")
else:
r = self.storage_batch_inbound(inbound_items)
logger.info(f"批量入库结果: {r}")
# 2. 新建实验2.14
r = self.create_orders(orders)
logger.info(f"新建实验结果: {r}")
# 3. 启动调度2.7
r = self.scheduler_start()
logger.info(f"启动调度结果: {r}")
# —— 运行中各类推送2.24 / 2.21 / 2.23),通常由实际任务驱动,这里提供调用方式 —— #
# self.report_material_change({...})
# self.report_step_finish(order_code="BSO...", order_name="配液分液", step_name="xxx", step_id="...", sample_id="...",
# start_time=_iso_utc_now_ms(), end_time=_iso_utc_now_ms(), execution_status="completed")
# self.report_order_finish(order_code="BSO...", order_name="配液分液", start_time="...", end_time=_iso_utc_now_ms())
# 完成后才能转运:用 2.5 批量查询配合 filter=任务编码 轮询到 status=80成功
if poll_filter_code:
import time
deadline = time.time() + poll_timeout_s
while time.time() < deadline:
res = self.order_list(status="80", filter_text=poll_filter_code, page=5)
if isinstance(res, dict) and res.get("data", {}).get("items"):
logger.info(f"实验 {poll_filter_code} 已完成:{res['data']['items'][0]}")
break
time.sleep(poll_interval_s)
else:
logger.warning(f"等待实验 {poll_filter_code} 完成超时(未到 status=80")
# 7. 启动 3-2-1 转运2.32
if transfer_source:
r = self.transfer_3_to_2_to_1(
source_wh_id=transfer_source.get("sourceWHID"),
source_x=transfer_source.get("sourcePosX", 1),
source_y=transfer_source.get("sourcePosY", 1),
source_z=transfer_source.get("sourcePosZ", 1),
)
logger.info(f"3-2-1 转运结果: {r}")
# 8. 1号站内部流程占位
self.start_station1_internal_flow()
# 9. 1→2 转运(占位)
self.transfer_1_to_2()
# 10. 样品/废料取出2.28
if takeout_order_id:
r = self.take_out(order_id=takeout_order_id)
logger.info(f"样品/废料取出结果: {r}")
# 2.5 批量查询实验报告
def order_list_v2(self,
timeType: str = "string",
beginTime: str = "",
endTime: str = "",
status: str = "",
filter: str = "物料转移任务",
skipCount: int = 0,
pageCount: int = 1,
sorting: str = "") -> Dict[str, Any]:
"""
批量查询实验报告的详细信息 (2.5)
URL: /api/lims/order/order-list
参数默认值和接口文档保持一致
"""
data: Dict[str, Any] = {
"timeType": timeType,
"beginTime": beginTime,
"endTime": endTime,
"status": status,
"filter": filter,
"skipCount": skipCount,
"pageCount": pageCount,
"sorting": sorting
}
return self._post_lims("/api/lims/order/order-list", data)
def wait_for_transfer_task(self, timeout: int = 600, interval: int = 3) -> bool:
"""
轮询查询物料转移任务是否成功完成 (status=80)
- timeout: 最大等待秒数 (默认600秒)
- interval: 轮询间隔秒数 (默认3秒)
返回 True 表示找到并成功完成False 表示超时未找到
"""
now = datetime.now()
beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ")
endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
print(beginTime, endTime)
deadline = time.time() + timeout
while time.time() < deadline:
result = self.order_list_v2(
timeType="string",
beginTime=beginTime,
endTime=endTime,
status="",
filter="物料转移任务",
skipCount=0,
pageCount=1,
sorting=""
)
print(result)
items = result.get("data", {}).get("items", [])
for item in items:
name = item.get("name", "")
status = item.get("status")
if name.startswith("物料转移任务") and status == 80:
logger.info(f"硬件转移动作完成: {name}")
return True
time.sleep(interval)
logger.warning("超时未找到成功的物料转移任务")
return False
# --------------------------------
if __name__ == "__main__":
ws = BioyondWorkstation()
# ws.scheduler_stop()
ws.scheduler_start()
logger.info("调度启动完成")
# ws.scheduler_continue()
# 3.30 上料:读取模板 Excel 自动解析并 POST
r1 = ws.auto_feeding4to3_from_xlsx(r"C:\ML\GitHub\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板 (8).xlsx")
ws.wait_for_transfer_task()
logger.info("4号箱向3号箱转运物料转移任务已完成")
# ws.scheduler_start()
# print(r1["payload"]["data"]) # 调试模式下可直接看到要发的 JSON items
# 新建实验
res = ws.create_orders("C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_cell/2025092501.xlsx")
# ws.scheduler_start()
# print(res)
#1号站启动
ws.transfer_1_to_2()
ws.wait_for_transfer_task()
logger.info("1号站向2号站转移任务完成")
logger.info("全流程结束")
# 3.31 下料:同理
# r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx")
# print(r2["payload"]["data"])

View File

@@ -113,7 +113,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine_four",
"type": "MagazineHolder_4",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -154,7 +154,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -249,7 +249,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -344,7 +344,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -439,7 +439,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -537,7 +537,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine_four",
"type": "MagazineHolder_4",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -578,7 +578,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -673,7 +673,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -768,7 +768,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -863,7 +863,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -963,7 +963,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -1006,7 +1006,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1101,7 +1101,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1196,7 +1196,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1291,7 +1291,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1386,7 +1386,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1481,7 +1481,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1581,7 +1581,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -1624,7 +1624,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1719,7 +1719,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1814,7 +1814,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1909,7 +1909,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2004,7 +2004,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2099,7 +2099,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2199,7 +2199,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -2242,7 +2242,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2337,7 +2337,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2432,7 +2432,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2527,7 +2527,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2622,7 +2622,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2717,7 +2717,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2817,7 +2817,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -2860,7 +2860,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2955,7 +2955,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3050,7 +3050,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3145,7 +3145,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3240,7 +3240,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3335,7 +3335,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3435,7 +3435,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -3478,7 +3478,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3573,7 +3573,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3668,7 +3668,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3763,7 +3763,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3858,7 +3858,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3953,7 +3953,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4053,7 +4053,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -4096,7 +4096,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4191,7 +4191,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4286,7 +4286,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4381,7 +4381,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4476,7 +4476,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4571,7 +4571,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,

View File

@@ -8,8 +8,8 @@ import os
# BioyondCellWorkstation 默认配置(包含所有必需参数)
API_CONFIG = {
# API 连接配置
# "api_host": os.getenv("BIOYOND_API_HOST", "http://172.21.32.65:44389"),#实机
"api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.10.169:44388"),# 仿真机
"api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.1.143:44389"),#实机
# "api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.7.149:44388"),# 仿真机
"api_key": os.getenv("BIOYOND_API_KEY", "8A819E5C"),
"timeout": int(os.getenv("BIOYOND_TIMEOUT", "30")),
@@ -17,7 +17,7 @@ API_CONFIG = {
"report_token": os.getenv("BIOYOND_REPORT_TOKEN", "CHANGE_ME_TOKEN"),
# HTTP 服务配置
"HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.21.32.83"), # HTTP服务监听地址监听计算机飞连ip地址
"HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.16.2.140"), # HTTP服务监听地址监听计算机飞连ip地址
"HTTP_port": int(os.getenv("BIOYOND_HTTP_PORT", "8080")),
"debug_mode": False,# 调试模式
}
@@ -234,22 +234,22 @@ WAREHOUSE_MAPPING = {
# 物料类型配置
MATERIAL_TYPE_MAPPINGS = {
"100ml液体": ("YB_1Bottle100mlCarrier", "d37166b3-ecaa-481e-bd84-3032b795ba07"),
"": ("YB_1BottleCarrier", "3a190ca1-2add-2b23-f8e1-bbd348b7f790"),
"高粘液": ("YB_1GaoNianYeBottleCarrier", "abe8df30-563d-43d2-85e0-cabec59ddc16"),
"加样头(大)": ("YB_jia_yang_tou_da", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
"加样头(大)板": ("YB_jia_yang_tou_da_1X1_carrier", "a8e714ae-2a4e-4eb9-9614-e4c140ec3f16"),
"5ml分液瓶板": ("YB_6x5ml_DispensingVialCarrier", "3a192fa4-007d-ec7b-456e-2a8be7a13f23"),
"5ml分液瓶": ("YB_fen_ye_5ml_Bottle", "3a192c2a-ebb7-58a1-480d-8b3863bf74f4"),
"20ml分液瓶板": ("YB_6x20ml_DispensingVialCarrier", "3a192fa4-47db-3449-162a-eaf8aba57e27"),
"20ml分液瓶": ("YB_fen_ye_20ml_Bottle", "3a192c2b-19e8-f0a3-035e-041ca8ca1035"),
"配液瓶(小)板": ("YB_6x_SmallSolutionBottleCarrier", "3a190c8b-3284-af78-d29f-9a69463ad047"),
"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_4x_LargeSolutionBottleCarrier", "53e50377-32dc-4781-b3c0-5ce45bc7dc27"),
"配液瓶(大)板": ("YB_peiyepingdaban", "53e50377-32dc-4781-b3c0-5ce45bc7dc27"),
"配液瓶(大)": ("YB_pei_ye_da_Bottle", "19c52ad1-51c5-494f-8854-576f4ca9c6ca"),
"适配器块": ("YB_AdapterBlock", "efc3bb32-d504-4890-91c0-b64ed3ac80cf"),
"枪头盒": ("YB_TipBox", "3a192c2e-20f3-a44a-0334-c8301839d0b3"),
"枪头": ("YB_Pipette_Tip", "b6196971-1050-46da-9927-333e8dea062d"),
"适配器块": ("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"),
}
SOLID_LIQUID_MAPPINGS = {

View File

@@ -21,7 +21,6 @@ from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNo
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
from pylabrobot.resources.resource import Resource as ResourcePLR
from unilabos.resources.bioyond.decks import YB_Deck
from unilabos.devices.workstation.bioyond_studio.config import (
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
)
@@ -64,7 +63,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.error("Bioyond API客户端未初始化")
return False
bioyond_data = self.bioyond_api_client.stock_material('{"typeMode": 1, "includeDetail": true}')
bioyond_data = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}')
if not bioyond_data:
logger.warning("从Bioyond获取的物料数据为空")
return False
@@ -138,7 +137,7 @@ class BioyondWorkstation(WorkstationBase):
# 初始化父类
super().__init__(
# 桌子
deck=YB_Deck("YB_Deck14"),
deck=deck,
*args,
**kwargs,
)
@@ -173,10 +172,23 @@ class BioyondWorkstation(WorkstationBase):
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
print("~~~",self._ros_node)
print("deck",self.deck)
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
})
def resource_tree_transfer(self, old_parent: ResourcePLR, plr_resource: ResourcePLR, parent_resource: ResourcePLR):
# ROS2DeviceNode.run_async_func(self._ros_node.resource_tree_transfer, True, **{
# "old_parent": old_parent,
# "plr_resource": plr_resource,
# "parent_resource": parent_resource,
# })
print("resource_tree_transfer", plr_resource, parent_resource)
if hasattr(plr_resource, "unilabos_data") and plr_resource.unilabos_data:
if "update_resource_site" in plr_resource.unilabos_data:
site = plr_resource.unilabos_data["update_resource_site"]
return
self.lab_logger().warning(f"无库位的上料,不处理,{plr_resource} 挂载到 {parent_resource}")
def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot):
ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{
"plr_resources": resource,

View File

@@ -1,3 +1,4 @@
"""
纽扣电池组装工作站物料类定义
Button Battery Assembly Station Resource Classes
@@ -387,17 +388,17 @@ class ClipMagazineHole(Container):
}
# TODO: 这个要改
class ClipMagazine(Resource):
class ClipMagazine(ItemizedResource[ClipMagazineHole]):
"""子弹夹类 - 有6个洞位每个洞位放多个极片"""
children: List[ClipMagazineHole]
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
hole_diameter: float = 20.0,
hole_depth: float = 50.0,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
hole_spacing: float = 25.0,
max_sheets_per_hole: int = 100,
category: str = "clip_magazine",
@@ -441,6 +442,7 @@ class ClipMagazine(Resource):
model=model,
)
# 保存洞位的直径和深度
self.hole_diameter = hole_diameter
self.hole_depth = hole_depth
self.max_sheets_per_hole = max_sheets_per_hole
@@ -744,7 +746,7 @@ class BottleRackState(TypedDict):
class BottleRack(Resource):
"""瓶架类 - 12个待配位置+12个已配位置"""
children: List[Bottle] = []
children: List[Resource] = []
def __init__(
self,
@@ -754,6 +756,12 @@ class BottleRack(Resource):
size_z: float,
category: str = "bottle_rack",
model: Optional[str] = None,
num_items_x: int = 3,
num_items_y: int = 4,
position_spacing: float = 35.0,
orientation: str = "horizontal",
padding_x: float = 20.0,
padding_y: float = 20.0,
):
"""初始化瓶架
@@ -773,13 +781,42 @@ class BottleRack(Resource):
category=category,
model=model,
)
# TODO: 添加瓶位坐标映射
self.index_to_pos = {
0: Coordinate.zero(),
1: Coordinate(x=1, y=2, z=3) # 添加
}
# 初始化状态
self._unilabos_state: BottleRackState = BottleRackState(
bottle_diameter=30.0,
bottle_height=100.0,
position_spacing=position_spacing,
name_to_index={},
)
# 基于网格生成瓶位坐标映射(居中摆放)
# 使用内边距避免点跑到容器外前端渲染不按mm等比缩放时更稳妥
origin_x = padding_x
origin_y = padding_y
self.index_to_pos = {}
for j in range(num_items_y):
for i in range(num_items_x):
idx = j * num_items_x + i
if orientation == "vertical":
# 纵向:沿 y 方向优先排列
self.index_to_pos[idx] = Coordinate(
x=origin_x + j * position_spacing,
y=origin_y + i * position_spacing,
z=0,
)
else:
# 横向(默认):沿 x 方向优先排列
self.index_to_pos[idx] = Coordinate(
x=origin_x + i * position_spacing,
y=origin_y + j * position_spacing,
z=0,
)
self.name_to_index = {}
self.name_to_pos = {}
self.num_items_x = num_items_x
self.num_items_y = num_items_y
self.orientation = orientation
self.padding_x = padding_x
self.padding_y = padding_y
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
@@ -789,20 +826,23 @@ class BottleRack(Resource):
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
data.update(
self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
# TODO: 这里有些问题要重新写一下
def assign_child_resource(self, resource: Bottle, location=Coordinate.zero(), reassign = True):
assert len(self.children) <= 12, "瓶架已满,无法添加更多瓶子"
def assign_child_resource_old(self, resource: Resource, location=Coordinate.zero(), reassign=True):
capacity = self.num_items_x * self.num_items_y
assert len(self.children) < capacity, "瓶架已满,无法添加更多瓶子"
index = len(self.children)
location = Coordinate(x=20 + (index % 4) * 15, y=20 + (index // 4) * 15, z=0)
location = self.index_to_pos.get(index, Coordinate.zero())
self.name_to_pos[resource.name] = location
self.name_to_index[resource.name] = index
return super().assign_child_resource(resource, location, reassign)
def assign_child_resource_by_index(self, resource: Bottle, index: int):
assert 0 <= index < 12, "无效的瓶子索引"
def assign_child_resource(self, resource: Resource, index: int):
capacity = self.num_items_x * self.num_items_y
assert 0 <= index < capacity, "无效的瓶子索引"
self.name_to_index[resource.name] = index
location = self.index_to_pos[index]
return super().assign_child_resource(resource, location)
@@ -811,10 +851,16 @@ class BottleRack(Resource):
super().unassign_child_resource(resource)
self.index_to_pos.pop(self.name_to_index.pop(resource.name, None), None)
# def serialize(self):
# self.children.sort(key=lambda x: self.name_to_index.get(x.name, 0))
# return super().serialize()
def serialize(self) -> dict:
return {
**super().serialize(),
"num_items_x": self.num_items_x,
"num_items_y": self.num_items_y,
"position_spacing": self._unilabos_state.get("position_spacing", 35.0),
"orientation": self.orientation,
"padding_x": self.padding_x,
"padding_y": self.padding_y,
}
class BottleState(TypedDict):
diameter: float
@@ -868,16 +914,83 @@ class Bottle(Resource):
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
class ClipMagazine_four(ItemizedResource[ClipMagazineHole]):
"""子弹夹类 - 有4个洞位每个洞位放多个极片"""
children: List[ClipMagazineHole]
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
hole_spacing: float = 25.0,
max_sheets_per_hole: int = 100,
category: str = "clip_magazine_four",
model: Optional[str] = None,
):
"""初始化子弹夹
Args:
name: 子弹夹名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
hole_diameter: 洞直径 (mm)
hole_depth: 洞深度 (mm)
hole_spacing: 洞位间距 (mm)
max_sheets_per_hole: 每个洞位最大极片数量
category: 类别
model: 型号
"""
# 创建4个洞位排成2x2布局
holes = create_ordered_items_2d(
klass=ClipMagazineHole,
num_items_x=2,
num_items_y=2,
dx=(size_x - 2 * hole_spacing) / 2, # 居中
dy=(size_y - hole_spacing) / 2, # 居中
dz=size_z - 0,
item_dx=hole_spacing,
item_dy=hole_spacing,
diameter=hole_diameter,
depth=hole_depth,
)
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=holes,
category=category,
model=model,
)
# 保存洞位的直径和深度
self.hole_diameter = hole_diameter
self.hole_depth = hole_depth
self.max_sheets_per_hole = max_sheets_per_hole
def serialize(self) -> dict:
return {
**super().serialize(),
"hole_diameter": self.hole_diameter,
"hole_depth": self.hole_depth,
"max_sheets_per_hole": self.max_sheets_per_hole,
}
class CoincellDeck(Deck):
"""纽扣电池组装工作站台面类"""
def __init__(
self,
name: str = "coin_cell_deck",
size_x: float = 1000.0, # 1m
size_y: float = 1000.0, # 1m
size_z: float = 900.0, # 0.9m
origin: Coordinate = Coordinate(0, 0, 0),
size_x: float = 3650.0, # 1m
size_y: float = 1550.0, # 1m
size_z: float = 2100.0, # 0.9m
origin: Coordinate = Coordinate(-4000, 2000, 0),
category: str = "coin_cell_deck",
setup: bool = False, # 是否自动执行 setup
):
@@ -904,59 +1017,131 @@ class CoincellDeck(Deck):
self.setup()
def setup(self) -> None:
"""设置工作站的标准布局 - 包含3个料盘"""
# 步骤 1: 创建所有料盘
self.plates = {
"liaopan1": MaterialPlate(
name="liaopan1",
size_x=120.8,
size_y=120.5,
size_z=10.0,
fill=True
),
"liaopan2": MaterialPlate(
name="liaopan2",
size_x=120.8,
size_y=120.5,
size_z=10.0,
fill=True
),
"电池料盘": MaterialPlate(
name="电池料盘",
size_x=120.8,
size_y=160.5,
size_z=10.0,
fill=True
),
}
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
# ====================================== 子弹夹 ============================================
zip_dan_jia = ClipMagazine_four("zi_dan_jia", 80, 80, 10)
self.assign_child_resource(zip_dan_jia, Coordinate(x=1400, y=50, z=0))
zip_dan_jia2 = ClipMagazine_four("zi_dan_jia2", 80, 80, 10)
self.assign_child_resource(zip_dan_jia2, Coordinate(x=1600, y=200, z=0))
zip_dan_jia3 = ClipMagazine("zi_dan_jia3", 80, 80, 10)
self.assign_child_resource(zip_dan_jia3, Coordinate(x=1500, y=200, z=0))
zip_dan_jia4 = ClipMagazine("zi_dan_jia4", 80, 80, 10)
self.assign_child_resource(zip_dan_jia4, Coordinate(x=1500, y=300, z=0))
zip_dan_jia5 = ClipMagazine("zi_dan_jia5", 80, 80, 10)
self.assign_child_resource(zip_dan_jia5, Coordinate(x=1600, y=300, z=0))
zip_dan_jia6 = ClipMagazine("zi_dan_jia6", 80, 80, 10)
self.assign_child_resource(zip_dan_jia6, Coordinate(x=1530, y=500, z=0))
zip_dan_jia7 = ClipMagazine("zi_dan_jia7", 80, 80, 10)
self.assign_child_resource(zip_dan_jia7, Coordinate(x=1180, y=400, z=0))
zip_dan_jia8 = ClipMagazine("zi_dan_jia8", 80, 80, 10)
self.assign_child_resource(zip_dan_jia8, Coordinate(x=1280, y=400, z=0))
# 步骤 2: 定义料盘在 deck 上的位置
# Deck 尺寸: 1000×1000mm料盘尺寸: 120.8×120.5mm 或 120.8×160.5mm
self.plate_locations = {
"liaopan1": Coordinate(x=50, y=50, z=0), # 左上角,留 50mm 边距
"liaopan2": Coordinate(x=250, y=50, z=0), # 中间liaopan1 右侧
"电池料盘": Coordinate(x=450, y=50, z=0), # 右侧
}
# 为子弹夹添加极片
for i in range(4):
jipian = ElectrodeSheet(name=f"zi_dan_jia_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
zip_dan_jia2.children[i].assign_child_resource(jipian, location=None)
for i in range(4):
jipian2 = ElectrodeSheet(name=f"zi_dan_jia2_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
zip_dan_jia.children[i].assign_child_resource(jipian2, location=None)
for i in range(6):
jipian3 = ElectrodeSheet(name=f"zi_dan_jia3_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
zip_dan_jia3.children[i].assign_child_resource(jipian3, location=None)
for i in range(6):
jipian4 = ElectrodeSheet(name=f"zi_dan_jia4_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
zip_dan_jia4.children[i].assign_child_resource(jipian4, location=None)
for i in range(6):
jipian5 = ElectrodeSheet(name=f"zi_dan_jia5_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
zip_dan_jia5.children[i].assign_child_resource(jipian5, location=None)
for i in range(6):
jipian6 = ElectrodeSheet(name=f"zi_dan_jia6_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
zip_dan_jia6.children[i].assign_child_resource(jipian6, location=None)
for i in range(6):
jipian7 = ElectrodeSheet(name=f"zi_dan_jia7_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
zip_dan_jia7.children[i].assign_child_resource(jipian7, location=None)
for i in range(6):
jipian8 = ElectrodeSheet(name=f"zi_dan_jia8_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
zip_dan_jia8.children[i].assign_child_resource(jipian8, location=None)
# 步骤 3: 将料盘分配到 deck 上
for plate_name, plate in self.plates.items():
self.assign_child_resource(
plate,
location=self.plate_locations[plate_name]
)
# 步骤 4: 为 liaopan1 添加初始极片
# ====================================== 物料板 ============================================
# 创建6个4*4的物料板
liaopan1 = MaterialPlate(name="liaopan1", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(liaopan1, Coordinate(x=1010, y=50, z=0))
for i in range(16):
jipian = ElectrodeSheet(
name=f"jipian1_{i}",
size_x=12,
size_y=12,
size_z=0.1
jipian_1 = ElectrodeSheet(name=f"{liaopan1.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
liaopan1.children[i].assign_child_resource(jipian_1, location=None)
liaopan2 = MaterialPlate(name="liaopan2", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(liaopan2, Coordinate(x=1130, y=50, z=0))
liaopan3 = MaterialPlate(name="liaopan3", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(liaopan3, Coordinate(x=1250, y=50, z=0))
liaopan4 = MaterialPlate(name="liaopan4", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(liaopan4, Coordinate(x=1010, y=150, z=0))
for i in range(16):
jipian_4 = ElectrodeSheet(name=f"{liaopan4.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
liaopan4.children[i].assign_child_resource(jipian_4, location=None)
liaopan5 = MaterialPlate(name="liaopan5", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(liaopan5, Coordinate(x=1130, y=150, z=0))
liaopan6 = MaterialPlate(name="liaopan6", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(liaopan6, Coordinate(x=1250, y=150, z=0))
# ====================================== 瓶架、移液枪 ============================================
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
bottle_rack_3x4 = BottleRack(
name="bottle_rack_3x4",
size_x=210.0,
size_y=140.0,
size_z=100.0,
num_items_x=3,
num_items_y=4,
position_spacing=35.0,
orientation="vertical",
)
self.plates["liaopan1"].children[i].assign_child_resource(
jipian,
location=None
self.assign_child_resource(bottle_rack_3x4, Coordinate(x=100, y=200, z=0))
bottle_rack_6x2 = BottleRack(
name="bottle_rack_6x2",
size_x=120.0,
size_y=250.0,
size_z=100.0,
num_items_x=6,
num_items_y=2,
position_spacing=35.0,
orientation="vertical",
)
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=300, y=300, z=0))
bottle_rack_6x2_2 = BottleRack(
name="bottle_rack_6x2_2",
size_x=120.0,
size_y=250.0,
size_z=100.0,
num_items_x=6,
num_items_y=2,
position_spacing=35.0,
orientation="vertical",
)
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=430, y=300, z=0))
# 将 ElectrodeSheet 放满 3x4 与 6x2 的所有孔位
for idx in range(bottle_rack_3x4.num_items_x * bottle_rack_3x4.num_items_y):
sheet = ElectrodeSheet(name=f"sheet_3x4_{idx}", size_x=12, size_y=12, size_z=0.1)
bottle_rack_3x4.assign_child_resource(sheet, index=idx)
for idx in range(bottle_rack_6x2.num_items_x * bottle_rack_6x2.num_items_y):
sheet = ElectrodeSheet(name=f"sheet_6x2_{idx}", size_x=12, size_y=12, size_z=0.1)
bottle_rack_6x2.assign_child_resource(sheet, index=idx)
tip_box = TipBox64(name="tip_box_64")
self.assign_child_resource(tip_box, Coordinate(x=300, y=100, z=0))
waste_tip_box = WasteTipBox(name="waste_tip_box")
self.assign_child_resource(waste_tip_box, Coordinate(x=300, y=200, z=0))
print(self)
def create_coin_cell_deck(name: str = "coin_cell_deck", size_x: float = 1000.0, size_y: float = 1000.0, size_z: float = 900.0) -> CoincellDeck:
@@ -971,6 +1156,13 @@ def create_coin_cell_deck(name: str = "coin_cell_deck", size_x: float = 1000.0,
Returns:
已配置好的 CoincellDeck 对象
"""
deck = CoincellDeck(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
deck.setup()
# 创建 CoincellDeck 实例并自动执行 setup 配置
deck = CoincellDeck(name=name, size_x=size_x, size_y=size_y, size_z=size_z, setup=True)
return deck
if __name__ == "__main__":
deck = create_coin_cell_deck()
print(deck)

View File

@@ -109,47 +109,25 @@ def _coerce_deck_input(deck: Any) -> Optional[Deck]:
#构建物料系统
class CoinCellAssemblyWorkstation(WorkstationBase):
def __init__(
self,
deck: Deck=None,
address: str = "172.21.32.111",
def __init__(self,
config: dict = None,
deck=None,
address: str = "172.16.28.102",
port: str = "502",
debug_mode: bool = False,
*args,
**kwargs,
):
if deck is None and "deck" in kwargs:
deck = kwargs.pop("deck")
else:
kwargs.pop("deck", None)
**kwargs):
normalized_deck = _coerce_deck_input(deck)
if deck is None and isinstance(normalized_deck, Deck):
deck = normalized_deck
super().__init__(
#桌子
deck=deck,
*args,
**kwargs,
)
if deck is None and config:
deck = config.get('deck')
if deck is None:
logger.info("没有传入依华deck检查启动json文件")
super().__init__(deck=deck, *args, **kwargs,)
self.debug_mode = debug_mode
# 如果没有传入 deck则创建标准配置的 deck
if self.deck is None:
self.deck = CoincellDeck(size_x=1000, size_y=1000, size_z=900, setup=True)
else:
# 如果传入了 deck 但还没有 setup可以选择是否 setup
if self.deck is not None and len(self.deck.children) == 0:
# deck 为空,执行 setup
self.deck.setup()
# 否则使用传入的 deck可能已经配置好了
self.deck = self.deck
""" 连接初始化 """
modbus_client = TCPClient(addr=address, port=port)
print("modbus_client", modbus_client)
logger.debug(f"创建 Modbus 客户端: {modbus_client}")
_ensure_modbus_slave_kw_alias(modbus_client.client)
if not debug_mode:
modbus_client.client.connect()
@@ -161,25 +139,20 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
time.sleep(2)
if not modbus_client.client.is_socket_open():
raise ValueError('modbus tcp connection failed')
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_1105.csv'))
self.client = modbus_client.register_node_list(self.nodes)
else:
print("测试模式,跳过连接")
self.nodes, self.client = None, None
""" 工站的配置 """
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
self.client = modbus_client.register_node_list(self.nodes)
self.success = False
self.allow_data_read = False #允许读取函数运行标志位
self.csv_export_thread = None
self.csv_export_running = False
self.csv_export_file = None
self.coin_num_N = 0 #已组装电池数量
#创建一个物料台面,包含两个极片板
#self._ros_node.update_resource(self.deck)
#ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
# "resources": [self.deck]
#})
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
@@ -602,11 +575,11 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
try:
# 尝试不同的字节序读取
code_little, read_err = self.client.use_node('REG_DATA_COIN_CELL_CODE').read(10, word_order=WorderOrder.LITTLE)
print(code_little)
# logger.debug(f"读取电池二维码原始数据: {code_little}")
clean_code = code_little[-8:][::-1]
return clean_code
except Exception as e:
print(f"读取电池二维码失败: {e}")
logger.error(f"读取电池二维码失败: {e}")
return "N/A"
@@ -615,11 +588,11 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
try:
# 尝试不同的字节序读取
code_little, read_err = self.client.use_node('REG_DATA_ELECTROLYTE_CODE').read(10, word_order=WorderOrder.LITTLE)
print(code_little)
# logger.debug(f"读取电解液二维码原始数据: {code_little}")
clean_code = code_little[-8:][::-1]
return clean_code
except Exception as e:
print(f"读取电解液二维码失败: {e}")
logger.error(f"读取电解液二维码失败: {e}")
return "N/A"
# ===================== 环境监控区 ======================
@@ -809,16 +782,16 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
data_coin_num = self.data_coin_num
data_electrolyte_code = self.data_electrolyte_code
data_coin_cell_code = self.data_coin_cell_code
print("data_open_circuit_voltage", data_open_circuit_voltage)
print("data_pole_weight", data_pole_weight)
print("data_assembly_time", data_assembly_time)
print("data_assembly_pressure", data_assembly_pressure)
print("data_electrolyte_volume", data_electrolyte_volume)
print("data_coin_num", data_coin_num)
print("data_electrolyte_code", data_electrolyte_code)
print("data_coin_cell_code", data_coin_cell_code)
logger.debug(f"data_open_circuit_voltage: {data_open_circuit_voltage}")
logger.debug(f"data_pole_weight: {data_pole_weight}")
logger.debug(f"data_assembly_time: {data_assembly_time}")
logger.debug(f"data_assembly_pressure: {data_assembly_pressure}")
logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}")
logger.debug(f"data_coin_num: {data_coin_num}")
logger.debug(f"data_electrolyte_code: {data_electrolyte_code}")
logger.debug(f"data_coin_cell_code: {data_coin_cell_code}")
#接收完信息后读取完毕标志位置True
liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
liaopan3 = self.deck.get_resource("chengpindanjia")
#把物料解绑后放到另一盘上
battery = ElectrodeSheet(name=f"battery_{self.coin_num_N}", size_x=14, size_y=14, size_z=2)
battery._unilabos_state = {
@@ -906,7 +879,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
return self.success
def func_allpack_cmd(self, elec_num, elec_use_num, elec_vol:int=50, assembly_type:int=7, assembly_pressure:int=4200, file_path: str="C:\\Users\\67484\\Desktop") -> bool:
def func_allpack_cmd(self, elec_num, elec_use_num, elec_vol:int=50, assembly_type:int=7, assembly_pressure:int=4200, file_path: str="/Users/sml/work") -> bool:
elec_num, elec_use_num, elec_vol, assembly_type, assembly_pressure = int(elec_num), int(elec_use_num), int(elec_vol), int(assembly_type), int(assembly_pressure)
summary_csv_file = os.path.join(file_path, "duandian.csv")
# 如果断点文件存在,先读取之前的进度
@@ -1233,7 +1206,13 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
if __name__ == "__main__":
# 简单测试
workstation = CoinCellAssemblyWorkstation()
workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False)
print(f"工作站创建成功: {workstation.deck.name}")
print(f"料盘数量: {len(workstation.deck.children)}")
workstation = CoinCellAssemblyWorkstation(deck=CoincellDeck(setup=True, name="coin_cell_deck"))
# workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False)
# print(f"工作站创建成功: {workstation.deck.name}")
# print(f"料盘数量: {len(workstation.deck.children)}")
workstation.func_pack_device_init()
workstation.func_pack_device_auto()
workstation.func_pack_device_start()
workstation.func_pack_send_bottle_num(16)
workstation.func_allpack_cmd(elec_num=16, elec_use_num=16, elec_vol=50, assembly_type=7, assembly_pressure=4200, file_path="/Users/calvincao/Desktop/work/Uni-Lab-OS-hhm")

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

@@ -14,6 +14,7 @@
},
"data": {}
},
{
"id": "BatteryStation",
"name": "扣电工作站",
@@ -24,8 +25,8 @@
"type": "device",
"class": "coincellassemblyworkstation_device",
"position": {
"x": 600,
"y": 400,
"x": -600,
"y": -400,
"z": 0
},
"config": {

View File

@@ -1,583 +0,0 @@
"""
工作站物料管理基类
Workstation Material Management Base Class
基于PyLabRobot的物料管理系统
"""
from typing import Dict, Any, List, Optional, Union, Type
from abc import ABC, abstractmethod
import json
from pylabrobot.resources import (
Resource as PLRResource,
Container,
Deck,
Coordinate as PLRCoordinate,
)
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
from unilabos.utils.log import logger
from unilabos.resources.graphio import resource_plr_to_ulab, resource_ulab_to_plr
class MaterialManagementBase(ABC):
"""物料管理基类
定义工作站物料管理的标准接口:
1. 物料初始化 - 根据配置创建物料资源
2. 物料追踪 - 实时跟踪物料位置和状态
3. 物料查找 - 按类型、位置、状态查找物料
4. 物料转换 - PyLabRobot与UniLab资源格式转换
"""
def __init__(
self,
device_id: str,
deck_config: Dict[str, Any],
resource_tracker: DeviceNodeResourceTracker,
children_config: Dict[str, Dict[str, Any]] = None
):
self.device_id = device_id
self.deck_config = deck_config
self.resource_tracker = resource_tracker
self.children_config = children_config or {}
# 创建主台面
self.plr_deck = self._create_deck()
# 扩展ResourceTracker
self._extend_resource_tracker()
# 注册deck到resource tracker
self.resource_tracker.add_resource(self.plr_deck)
# 初始化子资源
self.plr_resources = {}
self._initialize_materials()
def _create_deck(self) -> Deck:
"""创建主台面"""
return Deck(
name=f"{self.device_id}_deck",
size_x=self.deck_config.get("size_x", 1000.0),
size_y=self.deck_config.get("size_y", 1000.0),
size_z=self.deck_config.get("size_z", 500.0),
origin=PLRCoordinate(0, 0, 0)
)
def _extend_resource_tracker(self):
"""扩展ResourceTracker以支持PyLabRobot特定功能"""
def find_by_type(resource_type):
"""按类型查找资源"""
return self._find_resources_by_type_recursive(self.plr_deck, resource_type)
def find_by_category(category: str):
"""按类别查找资源"""
found = []
for resource in self._get_all_resources():
if hasattr(resource, 'category') and resource.category == category:
found.append(resource)
return found
def find_by_name_pattern(pattern: str):
"""按名称模式查找资源"""
import re
found = []
for resource in self._get_all_resources():
if re.search(pattern, resource.name):
found.append(resource)
return found
# 动态添加方法到resource_tracker
self.resource_tracker.find_by_type = find_by_type
self.resource_tracker.find_by_category = find_by_category
self.resource_tracker.find_by_name_pattern = find_by_name_pattern
def _find_resources_by_type_recursive(self, resource, target_type):
"""递归查找指定类型的资源"""
found = []
if isinstance(resource, target_type):
found.append(resource)
# 递归查找子资源
children = getattr(resource, "children", [])
for child in children:
found.extend(self._find_resources_by_type_recursive(child, target_type))
return found
def _get_all_resources(self) -> List[PLRResource]:
"""获取所有资源"""
all_resources = []
def collect_resources(resource):
all_resources.append(resource)
children = getattr(resource, "children", [])
for child in children:
collect_resources(child)
collect_resources(self.plr_deck)
return all_resources
def _initialize_materials(self):
"""初始化物料"""
try:
# 确定创建顺序,确保父资源先于子资源创建
creation_order = self._determine_creation_order()
# 按顺序创建资源
for resource_id in creation_order:
config = self.children_config[resource_id]
self._create_plr_resource(resource_id, config)
logger.info(f"物料管理系统初始化完成,共创建 {len(self.plr_resources)} 个资源")
except Exception as e:
logger.error(f"物料初始化失败: {e}")
def _determine_creation_order(self) -> List[str]:
"""确定资源创建顺序"""
order = []
visited = set()
def visit(resource_id: str):
if resource_id in visited:
return
visited.add(resource_id)
config = self.children_config.get(resource_id, {})
parent_id = config.get("parent")
# 如果有父资源,先访问父资源
if parent_id and parent_id in self.children_config:
visit(parent_id)
order.append(resource_id)
for resource_id in self.children_config:
visit(resource_id)
return order
def _create_plr_resource(self, resource_id: str, config: Dict[str, Any]):
"""创建PyLabRobot资源"""
try:
resource_type = config.get("type", "unknown")
data = config.get("data", {})
location_config = config.get("location", {})
# 创建位置坐标
location = PLRCoordinate(
x=location_config.get("x", 0.0),
y=location_config.get("y", 0.0),
z=location_config.get("z", 0.0)
)
# 根据类型创建资源
resource = self._create_resource_by_type(resource_id, resource_type, config, data, location)
if resource:
# 设置父子关系
parent_id = config.get("parent")
if parent_id and parent_id in self.plr_resources:
parent_resource = self.plr_resources[parent_id]
parent_resource.assign_child_resource(resource, location)
else:
# 直接放在deck上
self.plr_deck.assign_child_resource(resource, location)
# 保存资源引用
self.plr_resources[resource_id] = resource
# 注册到resource tracker
self.resource_tracker.add_resource(resource)
logger.debug(f"创建资源成功: {resource_id} ({resource_type})")
except Exception as e:
logger.error(f"创建资源失败 {resource_id}: {e}")
@abstractmethod
def _create_resource_by_type(
self,
resource_id: str,
resource_type: str,
config: Dict[str, Any],
data: Dict[str, Any],
location: PLRCoordinate
) -> Optional[PLRResource]:
"""根据类型创建资源 - 子类必须实现"""
pass
# ============ 物料查找接口 ============
def find_materials_by_type(self, material_type: str) -> List[PLRResource]:
"""按材料类型查找物料"""
return self.resource_tracker.find_by_category(material_type)
def find_material_by_id(self, resource_id: str) -> Optional[PLRResource]:
"""按ID查找物料"""
return self.plr_resources.get(resource_id)
def find_available_positions(self, position_type: str) -> List[PLRResource]:
"""查找可用位置"""
positions = self.resource_tracker.find_by_category(position_type)
available = []
for pos in positions:
if hasattr(pos, 'is_available') and pos.is_available():
available.append(pos)
elif hasattr(pos, 'children') and len(pos.children) == 0:
available.append(pos)
return available
def get_material_inventory(self) -> Dict[str, int]:
"""获取物料库存统计"""
inventory = {}
for resource in self._get_all_resources():
if hasattr(resource, 'category'):
category = resource.category
inventory[category] = inventory.get(category, 0) + 1
return inventory
# ============ 物料状态更新接口 ============
def update_material_location(self, material_id: str, new_location: PLRCoordinate) -> bool:
"""更新物料位置"""
try:
material = self.find_material_by_id(material_id)
if material:
material.location = new_location
return True
return False
except Exception as e:
logger.error(f"更新物料位置失败: {e}")
return False
def move_material(self, material_id: str, target_container_id: str) -> bool:
"""移动物料到目标容器"""
try:
material = self.find_material_by_id(material_id)
target = self.find_material_by_id(target_container_id)
if material and target:
# 从原位置移除
if material.parent:
material.parent.unassign_child_resource(material)
# 添加到新位置
target.assign_child_resource(material)
return True
return False
except Exception as e:
logger.error(f"移动物料失败: {e}")
return False
# ============ 资源转换接口 ============
def convert_to_unilab_format(self, plr_resource: PLRResource) -> Dict[str, Any]:
"""将PyLabRobot资源转换为UniLab格式"""
return resource_plr_to_ulab(plr_resource)
def convert_from_unilab_format(self, unilab_resource: Dict[str, Any]) -> PLRResource:
"""将UniLab格式转换为PyLabRobot资源"""
return resource_ulab_to_plr(unilab_resource)
def get_deck_state(self) -> Dict[str, Any]:
"""获取Deck状态"""
try:
return {
"deck_info": {
"name": self.plr_deck.name,
"size": {
"x": self.plr_deck.size_x,
"y": self.plr_deck.size_y,
"z": self.plr_deck.size_z
},
"children_count": len(self.plr_deck.children)
},
"resources": {
resource_id: self.convert_to_unilab_format(resource)
for resource_id, resource in self.plr_resources.items()
},
"inventory": self.get_material_inventory()
}
except Exception as e:
logger.error(f"获取Deck状态失败: {e}")
return {"error": str(e)}
# ============ 数据持久化接口 ============
def save_state_to_file(self, file_path: str) -> bool:
"""保存状态到文件"""
try:
state = self.get_deck_state()
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(state, f, indent=2, ensure_ascii=False)
logger.info(f"状态已保存到: {file_path}")
return True
except Exception as e:
logger.error(f"保存状态失败: {e}")
return False
def load_state_from_file(self, file_path: str) -> bool:
"""从文件加载状态"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
state = json.load(f)
# 重新创建资源
self._recreate_resources_from_state(state)
logger.info(f"状态已从文件加载: {file_path}")
return True
except Exception as e:
logger.error(f"加载状态失败: {e}")
return False
def _recreate_resources_from_state(self, state: Dict[str, Any]):
"""从状态重新创建资源"""
# 清除现有资源
self.plr_resources.clear()
self.plr_deck.children.clear()
# 从状态重新创建
resources_data = state.get("resources", {})
for resource_id, resource_data in resources_data.items():
try:
plr_resource = self.convert_from_unilab_format(resource_data)
self.plr_resources[resource_id] = plr_resource
self.plr_deck.assign_child_resource(plr_resource)
except Exception as e:
logger.error(f"重新创建资源失败 {resource_id}: {e}")
class CoinCellMaterialManagement(MaterialManagementBase):
"""纽扣电池物料管理类
从 button_battery_station 抽取的物料管理功能
"""
def _create_resource_by_type(
self,
resource_id: str,
resource_type: str,
config: Dict[str, Any],
data: Dict[str, Any],
location: PLRCoordinate
) -> Optional[PLRResource]:
"""根据类型创建纽扣电池相关资源"""
# 导入纽扣电池资源类
from unilabos.device_comms.button_battery_station import (
MaterialPlate, PlateSlot, ClipMagazine, BatteryPressSlot,
TipBox64, WasteTipBox, BottleRack, Battery, ElectrodeSheet
)
try:
if resource_type == "material_plate":
return self._create_material_plate(resource_id, config, data, location)
elif resource_type == "plate_slot":
return self._create_plate_slot(resource_id, config, data, location)
elif resource_type == "clip_magazine":
return self._create_clip_magazine(resource_id, config, data, location)
elif resource_type == "battery_press_slot":
return self._create_battery_press_slot(resource_id, config, data, location)
elif resource_type == "tip_box":
return self._create_tip_box(resource_id, config, data, location)
elif resource_type == "waste_tip_box":
return self._create_waste_tip_box(resource_id, config, data, location)
elif resource_type == "bottle_rack":
return self._create_bottle_rack(resource_id, config, data, location)
elif resource_type == "battery":
return self._create_battery(resource_id, config, data, location)
else:
logger.warning(f"未知的资源类型: {resource_type}")
return None
except Exception as e:
logger.error(f"创建资源失败 {resource_id} ({resource_type}): {e}")
return None
def _create_material_plate(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate):
"""创建料板"""
from unilabos.device_comms.button_battery_station import MaterialPlate, ElectrodeSheet
plate = MaterialPlate(
name=resource_id,
size_x=config.get("size_x", 80.0),
size_y=config.get("size_y", 80.0),
size_z=config.get("size_z", 10.0),
hole_diameter=config.get("hole_diameter", 15.0),
hole_depth=config.get("hole_depth", 8.0),
hole_spacing_x=config.get("hole_spacing_x", 20.0),
hole_spacing_y=config.get("hole_spacing_y", 20.0),
number=data.get("number", "")
)
plate.location = location
# 如果有预填充的极片数据,创建极片
electrode_sheets = data.get("electrode_sheets", [])
for i, sheet_data in enumerate(electrode_sheets):
if i < len(plate.children): # 确保不超过洞位数量
hole = plate.children[i]
sheet = ElectrodeSheet(
name=f"{resource_id}_sheet_{i}",
diameter=sheet_data.get("diameter", 14.0),
thickness=sheet_data.get("thickness", 0.1),
mass=sheet_data.get("mass", 0.01),
material_type=sheet_data.get("material_type", "cathode"),
info=sheet_data.get("info", "")
)
hole.place_electrode_sheet(sheet)
return plate
def _create_plate_slot(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate):
"""创建板槽位"""
from unilabos.device_comms.button_battery_station import PlateSlot
slot = PlateSlot(
name=resource_id,
max_plates=config.get("max_plates", 8)
)
slot.location = location
return slot
def _create_clip_magazine(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate):
"""创建子弹夹"""
from unilabos.device_comms.button_battery_station import ClipMagazine
magazine = ClipMagazine(
name=resource_id,
size_x=config.get("size_x", 150.0),
size_y=config.get("size_y", 100.0),
size_z=config.get("size_z", 50.0),
hole_diameter=config.get("hole_diameter", 15.0),
hole_depth=config.get("hole_depth", 40.0),
hole_spacing=config.get("hole_spacing", 25.0),
max_sheets_per_hole=config.get("max_sheets_per_hole", 100)
)
magazine.location = location
return magazine
def _create_battery_press_slot(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate):
"""创建电池压制槽"""
from unilabos.device_comms.button_battery_station import BatteryPressSlot
slot = BatteryPressSlot(
name=resource_id,
diameter=config.get("diameter", 20.0),
depth=config.get("depth", 15.0)
)
slot.location = location
return slot
def _create_tip_box(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate):
"""创建枪头盒"""
from unilabos.device_comms.button_battery_station import TipBox64
tip_box = TipBox64(
name=resource_id,
size_x=config.get("size_x", 127.8),
size_y=config.get("size_y", 85.5),
size_z=config.get("size_z", 60.0),
with_tips=data.get("with_tips", True)
)
tip_box.location = location
return tip_box
def _create_waste_tip_box(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate):
"""创建废枪头盒"""
from unilabos.device_comms.button_battery_station import WasteTipBox
waste_box = WasteTipBox(
name=resource_id,
size_x=config.get("size_x", 127.8),
size_y=config.get("size_y", 85.5),
size_z=config.get("size_z", 60.0),
max_tips=config.get("max_tips", 100)
)
waste_box.location = location
return waste_box
def _create_bottle_rack(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate):
"""创建瓶架"""
from unilabos.device_comms.button_battery_station import BottleRack
rack = BottleRack(
name=resource_id,
size_x=config.get("size_x", 210.0),
size_y=config.get("size_y", 140.0),
size_z=config.get("size_z", 100.0),
bottle_diameter=config.get("bottle_diameter", 30.0),
bottle_height=config.get("bottle_height", 100.0),
position_spacing=config.get("position_spacing", 35.0)
)
rack.location = location
return rack
def _create_battery(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate):
"""创建电池"""
from unilabos.device_comms.button_battery_station import Battery
battery = Battery(
name=resource_id,
diameter=config.get("diameter", 20.0),
height=config.get("height", 3.2),
max_volume=config.get("max_volume", 100.0),
barcode=data.get("barcode", "")
)
battery.location = location
return battery
# ============ 纽扣电池特定查找方法 ============
def find_material_plates(self):
"""查找所有料板"""
from unilabos.device_comms.button_battery_station import MaterialPlate
return self.resource_tracker.find_by_type(MaterialPlate)
def find_batteries(self):
"""查找所有电池"""
from unilabos.device_comms.button_battery_station import Battery
return self.resource_tracker.find_by_type(Battery)
def find_electrode_sheets(self):
"""查找所有极片"""
found = []
plates = self.find_material_plates()
for plate in plates:
for hole in plate.children:
if hasattr(hole, 'has_electrode_sheet') and hole.has_electrode_sheet():
found.append(hole._electrode_sheet)
return found
def find_plate_slots(self):
"""查找所有板槽位"""
from unilabos.device_comms.button_battery_station import PlateSlot
return self.resource_tracker.find_by_type(PlateSlot)
def find_clip_magazines(self):
"""查找所有子弹夹"""
from unilabos.device_comms.button_battery_station import ClipMagazine
return self.resource_tracker.find_by_type(ClipMagazine)
def find_press_slots(self):
"""查找所有压制槽"""
from unilabos.device_comms.button_battery_station import BatteryPressSlot
return self.resource_tracker.find_by_type(BatteryPressSlot)

View File

@@ -4,6 +4,7 @@ bioyond_cell:
class:
action_value_mappings:
auto-auto_batch_outbound_from_xlsx:
display_name: 批量导入上料
feedback: {}
goal: {}
goal_default:
@@ -137,7 +138,7 @@ bioyond_cell:
WH4_x5_y1_z1_5_quantity: 0.0
WH4_x5_y2_z1_10_materialName: ''
WH4_x5_y2_z1_10_quantity: 0.0
xlsx_path: unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx
xlsx_path: C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx
handles: {}
placeholder_keys: {}
result: {}
@@ -463,7 +464,7 @@ bioyond_cell:
default: 0.0
type: number
xlsx_path:
default: unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx
default: C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx
type: string
required: []
type: object
@@ -473,31 +474,6 @@ bioyond_cell:
title: auto_feeding4to3参数
type: object
type: UniLabJsonCommand
auto-auto_feeding4to3_from_xlsx:
feedback: {}
goal: {}
goal_default:
xlsx_path: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
xlsx_path:
type: string
required:
- xlsx_path
type: object
result: {}
required:
- goal
title: auto_feeding4to3_from_xlsx参数
type: object
type: UniLabJsonCommand
auto-create_and_inbound_materials:
feedback: {}
goal: {}
@@ -1039,7 +1015,7 @@ bioyond_cell:
goal: {}
goal_default:
order_code: null
timeout: 1800
timeout: 36000
handles: {}
placeholder_keys: {}
result: {}
@@ -1052,7 +1028,7 @@ bioyond_cell:
order_code:
type: string
timeout:
default: 1800
default: 36000
type: integer
required:
- order_code
@@ -1105,9 +1081,11 @@ bioyond_cell:
init_param_schema:
config:
properties:
bioyond_config:
config:
type: object
deck:
type: string
station_resource:
protocol_type:
type: string
required: []
type: object

View File

@@ -79,7 +79,7 @@ coincellassemblyworkstation_device:
elec_num: null
elec_use_num: null
elec_vol: 50
file_path: D:\coin_cell_data
file_path: C:\Users\67484\Desktop
handles: {}
placeholder_keys: {}
result: {}
@@ -103,7 +103,7 @@ coincellassemblyworkstation_device:
default: 50
type: integer
file_path:
default: D:\coin_cell_data
default: C:\Users\67484\Desktop
type: string
required:
- elec_num
@@ -502,7 +502,7 @@ coincellassemblyworkstation_device:
config_info: []
description: ''
handles: []
icon: ''
icon: coin_cell_assembly_picture.webp
init_param_schema:
config:
properties:

View File

@@ -1,37 +1,37 @@
YB_Pipette_Tip:
YB_qiang_tou:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_Pipette_Tip
module: unilabos.resources.bioyond.YB_bottles:YB_qiang_tou
type: pylabrobot
description: YB_Pipette_Tip
description: YB_qiang_tou
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_fen_ye_20ml_Bottle:
YB_20ml_fenyeping:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_fen_ye_20ml_Bottle
module: unilabos.resources.bioyond.YB_bottles:YB_20ml_fenyeping
type: pylabrobot
description: YB_fen_ye_20ml_Bottle
description: YB_20ml_fenyeping
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_fen_ye_5ml_Bottle:
YB_5ml_fenyeping:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_fen_ye_5ml_Bottle
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping
type: pylabrobot
description: YB_fen_ye_5ml_Bottle
description: YB_5ml_fenyeping
handles: []
icon: ''
init_param_schema: {}
@@ -63,3 +63,29 @@ YB_pei_ye_xiao_Bottle:
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_jia_yang_tou_da:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da
type: pylabrobot
description: YB_jia_yang_tou_da
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_ye_Bottle:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle
type: pylabrobot
description: YB_ye_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -1,50 +1,50 @@
YB_1Bottle100mlCarrier:
YB_100ml_yeti:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_1Bottle100mlCarrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_100ml_yeti
type: pylabrobot
description: YB_1Bottle100mlCarrier
description: YB_100ml_yeti
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_1BottleCarrier:
# YB_1BottleCarrier:
# category:
# - yb3
# - YB_bottle_carriers
# class:
# module: unilabos.resources.bioyond.YB_bottle_carriers:YB_1BottleCarrier
# type: pylabrobot
# description: YB_1BottleCarrier
# handles: []
# icon: ''
# init_param_schema: {}
# registry_type: resource
# version: 1.0.0
YB_gaonianye:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_1BottleCarrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_gaonianye
type: pylabrobot
description: YB_1BottleCarrier
description: YB_gaonianye
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_1GaoNianYeBottleCarrier:
YB_peiyepingdaban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_1GaoNianYeBottleCarrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingdaban
type: pylabrobot
description: YB_1GaoNianYeBottleCarrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_4x_LargeSolutionBottleCarrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_4x_LargeSolutionBottleCarrier
type: pylabrobot
description: YB_4x_LargeSolutionBottleCarrier
description: YB_peiyepingdaban
handles: []
icon: ''
init_param_schema: {}
@@ -76,66 +76,66 @@ YB_6VialCarrier:
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_6x20ml_DispensingVialCarrier:
YB_20ml_fenyepingban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6x20ml_DispensingVialCarrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_20ml_fenyepingban
type: pylabrobot
description: YB_6x20ml_DispensingVialCarrier
description: YB_20ml_fenyepingban
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_6x5ml_DispensingVialCarrier:
YB_5ml_fenyepingban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6x5ml_DispensingVialCarrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban
type: pylabrobot
description: YB_6x5ml_DispensingVialCarrier
description: YB_5ml_fenyepingban
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_6x_SmallSolutionBottleCarrier:
YB_peiyepingxiaoban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6x_SmallSolutionBottleCarrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban
type: pylabrobot
description: YB_6x_SmallSolutionBottleCarrier
description: YB_peiyepingxiaoban
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_AdapterBlock:
YB_shi_pei_qi_kuai:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_AdapterBlock
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_shi_pei_qi_kuai
type: pylabrobot
description: YB_AdapterBlock
description: YB_shi_pei_qi_kuai
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_TipBox:
YB_qiang_tou_he:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipBox
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_qiang_tou_he
type: pylabrobot
description: YB_TipBox
description: YB_qiang_tou_he
handles: []
icon: ''
init_param_schema: {}
@@ -154,27 +154,27 @@ YB_gao_nian_ye_Bottle:
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_jia_yang_tou_da:
# YB_jia_yang_tou_da:
# category:
# - yb3
# - YB_bottle_carriers
# class:
# module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da
# type: pylabrobot
# description: YB_jia_yang_tou_da
# handles: []
# icon: ''
# init_param_schema: {}
# registry_type: resource
# version: 1.0.0
YB_jia_yang_tou_da_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_Carrier
type: pylabrobot
description: YB_jia_yang_tou_da
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_jia_yang_tou_da_1X1_carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_1X1_carrier
type: pylabrobot
description: YB_jia_yang_tou_da_1X1_carrier
description: YB_jia_yang_tou_da_Carrier
handles: []
icon: ''
init_param_schema: {}
@@ -193,14 +193,14 @@ YB_ye_100ml_Bottle:
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_ye_Bottle:
YB_ye:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_ye
type: pylabrobot
description: YB_ye_Bottle
description: YB_ye_Bottle_Carrier
handles: []
icon: ''
init_param_schema: {}

View File

@@ -22,15 +22,27 @@ BIOYOND_PolymerReactionStation_Deck:
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_Deck16:
BIOYOND_YB_Deck:
category:
- deck
class:
module: unilabos.resources.bioyond.decks:YB_Deck
module: unilabos.resources.bioyond.decks:BIOYOND_YB_Deck
type: pylabrobot
description: BIOYOND PolymerReactionStation Deck
description: BIOYOND_YB_Deck
handles: []
icon: 配液站.webp
init_param_schema: {}
registry_type: resource
version: 1.0.0
CoincellDeck:
category:
- deck
class:
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck
type: pylabrobot
description: CoincellDeck
handles: []
icon: yihua.webp
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -0,0 +1,56 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
from unilabos.resources.bioyond.YB_bottles import (
YB_pei_ye_xiao_Bottle,
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier:
"""12瓶载架 - 2x6布局"""
# 载架尺寸 (mm)
carrier_size_x = 120.0
carrier_size_y = 250.0
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 35.0
bottle_spacing_x = 35.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (6 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=2,
num_items_y=6,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="Electrolyte_12VialCarrier",
)
carrier.num_items_x = 2
carrier.num_items_y = 6
carrier.num_items_z = 1
for i in range(12):
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
return carrier

View File

@@ -0,0 +1,179 @@
from typing import Any, Dict, Optional, TypedDict
from pylabrobot.resources import Resource as ResourcePLR
from pylabrobot.resources import Container
electrode_colors = {
"PositiveCan": "#ff0000",
"PositiveElectrode": "#cc3333",
"NegativeCan": "#000000",
"NegativeElectrode": "#666666",
"SpringWasher": "#8b7355",
"FlatWasher": "a9a9a9",
"AluminumFoil": "#ffcccc",
"Battery": "#00ff00",
}
class ElectrodeSheetState(TypedDict):
mass: float # 质量 (g)
material_type: str # 材料类型(铜、铝、不锈钢、弹簧钢等)
color: str # 材料类型对应的颜色
class ElectrodeSheet(ResourcePLR):
"""极片类 - 包含正负极片、隔膜、弹片、垫片、铝箔等所有片状材料"""
def __init__(
self,
name: str = "极片",
size_x=10,
size_y=10,
size_z=10,
category: str = "electrode_sheet",
model: Optional[str] = None,
):
"""初始化极片
Args:
name: 极片名称
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: ElectrodeSheetState = ElectrodeSheetState(
diameter=14,
thickness=0.1,
mass=0.5,
material_type="copper",
info=None
)
# TODO: 这个还要不要给self._unilabos_state赋值的
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 PositiveCan(name: str) -> ElectrodeSheet:
"""创建正极壳"""
sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=3.0, model="PositiveCan")
sheet.load_state({"material_type": "aluminum", "color": electrode_colors["PositiveCan"]})
return sheet
def PositiveElectrode(name: str) -> ElectrodeSheet:
"""创建正极片"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="PositiveElectrode")
sheet.load_state({"material_type": "positive_electrode", "color": electrode_colors["PositiveElectrode"]})
return sheet
def NegativeCan(name: str) -> ElectrodeSheet:
"""创建负极壳"""
sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=2.0, model="NegativeCan")
sheet.load_state({"material_type": "steel", "color": electrode_colors["NegativeCan"]})
return sheet
def NegativeElectrode(name: str) -> ElectrodeSheet:
"""创建负极片"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="NegativeElectrode")
sheet.load_state({"material_type": "negative_electrode", "color": electrode_colors["NegativeElectrode"]})
return sheet
def SpringWasher(name: str) -> ElectrodeSheet:
"""创建弹片"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.5, model="SpringWasher")
sheet.load_state({"material_type": "spring_steel", "color": electrode_colors["SpringWasher"]})
return sheet
def FlatWasher(name: str) -> ElectrodeSheet:
"""创建垫片"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.2, model="FlatWasher")
sheet.load_state({"material_type": "steel", "color": electrode_colors["FlatWasher"]})
return sheet
def AluminumFoil(name: str) -> ElectrodeSheet:
"""创建铝箔"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.05, model="AluminumFoil")
sheet.load_state({"material_type": "aluminum", "color": electrode_colors["AluminumFoil"]})
return sheet
class BatteryState(TypedDict):
color: str # 材料类型对应的颜色
electrolyte_name: str
data_electrolyte_code: str
open_circuit_voltage: float
assembly_pressure: float
electrolyte_volume: float
info: Optional[str] # 附加信息
class Battery(Container):
"""电池类 - 包含组装好的电池"""
def __init__(
self,
name: str = "电池",
size_x=12,
size_y=12,
size_z=6,
category: str = "battery",
model: Optional[str] = None,
):
"""初始化电池
Args:
name: 电池名称
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: BatteryState = BatteryState(
color=electrode_colors["Battery"],
electrolyte_name="",
data_electrolyte_code="",
open_circuit_voltage=0.0,
assembly_pressure=0.0,
electrolyte_volume=0.0,
info=None
)
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

View File

@@ -0,0 +1,335 @@
from typing import Dict, List, Optional, OrderedDict, Union, Callable
import math
from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources import Resource, ResourceStack, ItemizedResource
from pylabrobot.resources.carrier import create_homogeneous_resources
from unilabos.resources.battery.electrode_sheet import (
PositiveCan, PositiveElectrode,
NegativeCan, NegativeElectrode,
SpringWasher, FlatWasher,
AluminumFoil,
Battery
)
class Magazine(ResourceStack):
"""子弹夹洞位类"""
def __init__(
self,
name: str,
direction: str = 'z',
resources: Optional[List[Resource]] = None,
max_sheets: int = 100,
**kwargs
):
"""初始化子弹夹洞位
Args:
name: 洞位名称
direction: 堆叠方向
resources: 资源列表
max_sheets: 最大极片数量
"""
super().__init__(
name=name,
direction=direction,
resources=resources,
)
self.max_sheets = max_sheets
@property
def size_x(self) -> float:
return self.get_size_x()
@property
def size_y(self) -> float:
return self.get_size_y()
@property
def size_z(self) -> float:
return self.get_size_z()
class MagazineHolder(ItemizedResource):
"""子弹夹类 - 有多个洞位,每个洞位放多个极片"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
ordered_items: Optional[Dict[str, Magazine]] = None,
ordering: Optional[OrderedDict[str, str]] = None,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
max_sheets_per_hole: int = 100,
cross_section_type: str = "circle",
category: str = "magazine_holder",
model: Optional[str] = None,
):
"""初始化子弹夹
Args:
name: 子弹夹名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
hole_diameter: 洞直径 (mm)
hole_depth: 洞深度 (mm)
max_sheets_per_hole: 每个洞位最大极片数量
category: 类别
model: 型号
"""
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,
)
# 保存洞位的直径和深度
self.hole_diameter = hole_diameter
self.hole_depth = hole_depth
self.max_sheets_per_hole = max_sheets_per_hole
self.cross_section_type = cross_section_type
def serialize(self) -> dict:
return {
**super().serialize(),
"hole_diameter": self.hole_diameter,
"hole_depth": self.hole_depth,
"max_sheets_per_hole": self.max_sheets_per_hole,
"cross_section_type": self.cross_section_type,
}
def magazine_factory(
name: str,
size_x: float,
size_y: float,
size_z: float,
locations: List[Coordinate],
klasses: Optional[List[Callable[[str], str]]] = None,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
max_sheets_per_hole: int = 100,
category: str = "magazine_holder",
model: Optional[str] = None,
) -> 'MagazineHolder':
"""工厂函数:创建子弹夹
Args:
name: 子弹夹名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
locations: 洞位坐标列表
klasses: 每个洞位中极片的类列表
hole_diameter: 洞直径 (mm)
hole_depth: 洞深度 (mm)
max_sheets_per_hole: 每个洞位最大极片数量
category: 类别
model: 型号
"""
for loc in locations:
loc.x -= hole_diameter / 2
loc.y -= hole_diameter / 2
# 创建洞位
_sites = create_homogeneous_resources(
klass=Magazine,
locations=locations,
resource_size_x=hole_diameter,
resource_size_y=hole_diameter,
name_prefix=name,
max_sheets=max_sheets_per_hole,
)
# 生成编号键
keys = [f"A{i+1}" for i in range(len(locations))]
sites = dict(zip(keys, _sites.values()))
holder = MagazineHolder(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=sites,
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category=category,
model=model,
)
if klasses is not None:
for i, klass in enumerate(klasses):
hole_key = keys[i]
hole = holder.children[i]
for j in reversed(range(max_sheets_per_hole)):
item_name = f"{hole_key}_sheet{j+1}"
item = klass(name=item_name)
hole.assign_child_resource(item)
return holder
def MagazineHolder_6_Cathode(
name: str,
size_x: float = 80.0,
size_y: float = 80.0,
size_z: float = 40.0,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
hole_spacing: float = 20.0,
max_sheets_per_hole: int = 100,
) -> MagazineHolder:
"""创建6孔子弹夹 - 六边形排布"""
center_x = size_x / 2
center_y = size_y / 2
locations = []
# 周围6个孔按六边形排布
for i in range(6):
angle = i * 60 * math.pi / 180 # 每60度一个孔
x = center_x + hole_spacing * math.cos(angle)
y = center_y + hole_spacing * math.sin(angle)
locations.append(Coordinate(x, y, size_z - hole_depth))
return magazine_factory(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category="magazine_holder",
model="MagazineHolder_6_Cathode",
)
def MagazineHolder_6_Anode(
name: str,
size_x: float = 80.0,
size_y: float = 80.0,
size_z: float = 40.0,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
hole_spacing: float = 20.0,
max_sheets_per_hole: int = 100,
) -> MagazineHolder:
"""创建6孔子弹夹 - 六边形排布"""
center_x = size_x / 2
center_y = size_y / 2
locations = []
# 周围6个孔按六边形排布
for i in range(6):
angle = i * 60 * math.pi / 180 # 每60度一个孔
x = center_x + hole_spacing * math.cos(angle)
y = center_y + hole_spacing * math.sin(angle)
locations.append(Coordinate(x, y, size_z - hole_depth))
return magazine_factory(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category="magazine_holder",
model="MagazineHolder_6_Anode",
)
def MagazineHolder_6_Battery(
name: str,
size_x: float = 80.0,
size_y: float = 80.0,
size_z: float = 40.0,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
hole_spacing: float = 20.0,
max_sheets_per_hole: int = 100,
) -> MagazineHolder:
"""创建6孔子弹夹 - 六边形排布"""
center_x = size_x / 2
center_y = size_y / 2
locations = []
# 周围6个孔按六边形排布
for i in range(6):
angle = i * 60 * math.pi / 180 # 每60度一个孔
x = center_x + hole_spacing * math.cos(angle)
y = center_y + hole_spacing * math.sin(angle)
locations.append(Coordinate(x, y, size_z - hole_depth))
return magazine_factory(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=None, # 初始化时,不放入装好的电池
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category="magazine_holder",
model="MagazineHolder_6_Battery",
)
def MagazineHolder_4_Cathode(
name: str,
) -> MagazineHolder:
"""创建4孔子弹夹 - 正方形四角排布"""
size_x: float = 80.0
size_y: float = 80.0
size_z: float = 10.0
hole_diameter: float = 14.0
hole_depth: float = 10.0
hole_spacing: float = 25.0
max_sheets_per_hole: int = 100
# 计算4个洞位的坐标正方形四角排布
center_x = size_x / 2
center_y = size_y / 2
offset = hole_spacing / 2
locations = [
Coordinate(center_x - offset, center_y - offset, size_z - hole_depth), # 左下
Coordinate(center_x + offset, center_y - offset, size_z - hole_depth), # 右下
Coordinate(center_x - offset, center_y + offset, size_z - hole_depth), # 左上
Coordinate(center_x + offset, center_y + offset, size_z - hole_depth), # 右上
]
return magazine_factory(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category="magazine_holder",
model="MagazineHolder_4_Cathode",
)

View File

@@ -6,11 +6,11 @@ from unilabos.resources.bioyond.YB_bottles import (
YB_ye_Bottle,
YB_ye_100ml_Bottle,
YB_gao_nian_ye_Bottle,
YB_fen_ye_5ml_Bottle,
YB_fen_ye_20ml_Bottle,
YB_5ml_fenyeping,
YB_20ml_fenyeping,
YB_pei_ye_xiao_Bottle,
YB_pei_ye_da_Bottle,
YB_Pipette_Tip,
YB_qiang_tou,
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
@@ -206,7 +206,7 @@ def YB_6VialCarrier(name: str) -> BottleCarrier:
return carrier
# 1瓶载架 - 单个中央位置
def YB_1BottleCarrier(name: str) -> BottleCarrier:
def YB_ye(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -233,7 +233,7 @@ def YB_1BottleCarrier(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="1BottleCarrier",
model="YB_ye",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
@@ -243,7 +243,7 @@ def YB_1BottleCarrier(name: str) -> BottleCarrier:
# 高粘液瓶载架 - 单个中央位置
def YB_1GaoNianYeBottleCarrier(name: str) -> BottleCarrier:
def YB_gaonianye(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -270,7 +270,7 @@ def YB_1GaoNianYeBottleCarrier(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="1GaoNianYeBottleCarrier",
model="YB_gaonianye",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
@@ -280,7 +280,7 @@ def YB_1GaoNianYeBottleCarrier(name: str) -> BottleCarrier:
# 100ml液体瓶载架 - 单个中央位置
def YB_1Bottle100mlCarrier(name: str) -> BottleCarrier:
def YB_100ml_yeti(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -307,7 +307,7 @@ def YB_1Bottle100mlCarrier(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="1Bottle100mlCarrier",
model="YB_100ml_yeti",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
@@ -316,7 +316,7 @@ def YB_1Bottle100mlCarrier(name: str) -> BottleCarrier:
return carrier
# 5ml分液瓶板 - 4x2布局8个位置
def YB_6x5ml_DispensingVialCarrier(name: str) -> BottleCarrier:
def YB_5ml_fenyepingban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -355,18 +355,18 @@ def YB_6x5ml_DispensingVialCarrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="6x5ml_DispensingVialCarrier",
model="YB_5ml_fenyepingban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
for i in range(8):
carrier[i] = YB_fen_ye_5ml_Bottle(f"{name}_vial_{ordering[i]}")
carrier[i] = YB_5ml_fenyeping(f"{name}_vial_{ordering[i]}")
return carrier
# 20ml分液瓶板 - 4x2布局8个位置
def YB_6x20ml_DispensingVialCarrier(name: str) -> BottleCarrier:
def YB_20ml_fenyepingban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -405,18 +405,18 @@ def YB_6x20ml_DispensingVialCarrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="6x20ml_DispensingVialCarrier",
model="YB_20ml_fenyepingban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
for i in range(8):
carrier[i] = YB_fen_ye_20ml_Bottle(f"{name}_vial_{ordering[i]}")
carrier[i] = YB_20ml_fenyeping(f"{name}_vial_{ordering[i]}")
return carrier
# 配液瓶(小)板 - 4x2布局8个位置
def YB_6x_SmallSolutionBottleCarrier(name: str) -> BottleCarrier:
def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -455,7 +455,7 @@ def YB_6x_SmallSolutionBottleCarrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="6x_SmallSolutionBottleCarrier",
model="YB_peiyepingxiaoban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
@@ -467,7 +467,7 @@ def YB_6x_SmallSolutionBottleCarrier(name: str) -> BottleCarrier:
# 配液瓶(大)板 - 2x2布局4个位置
def YB_4x_LargeSolutionBottleCarrier(name: str) -> BottleCarrier:
def YB_peiyepingdaban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -505,7 +505,7 @@ def YB_4x_LargeSolutionBottleCarrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="4x_LargeSolutionBottleCarrier",
model="YB_peiyepingdaban",
)
carrier.num_items_x = 2
carrier.num_items_y = 2
@@ -516,7 +516,7 @@ def YB_4x_LargeSolutionBottleCarrier(name: str) -> BottleCarrier:
return carrier
# 加样头(大)板 - 1x1布局1个位置
def YB_jia_yang_tou_da_1X1_carrier(name: str) -> BottleCarrier:
def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -554,7 +554,7 @@ def YB_jia_yang_tou_da_1X1_carrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="6x_LargeDispenseHeadCarrier",
model="YB_jia_yang_tou_da_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
@@ -563,7 +563,7 @@ def YB_jia_yang_tou_da_1X1_carrier(name: str) -> BottleCarrier:
return carrier
def YB_AdapterBlock(name: str) -> BottleCarrier:
def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
"""适配器块 - 单个中央位置"""
# 载架尺寸 (mm)
@@ -591,7 +591,7 @@ def YB_AdapterBlock(name: str) -> BottleCarrier:
resource_size_y=adapter_diameter,
name_prefix=name,
),
model="AdapterBlock",
model="YB_shi_pei_qi_kuai",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
@@ -600,7 +600,7 @@ def YB_AdapterBlock(name: str) -> BottleCarrier:
return carrier
def YB_TipBox(name: str) -> BottleCarrier:
def YB_qiang_tou_he(name: str) -> BottleCarrier:
"""枪头盒 - 8x12布局96个位置"""
# 载架尺寸 (mm)
@@ -639,7 +639,7 @@ def YB_TipBox(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="TipBox",
model="YB_qiang_tou_he",
)
carrier.num_items_x = 12
carrier.num_items_y = 8
@@ -648,6 +648,6 @@ def YB_TipBox(name: str) -> BottleCarrier:
for i in range(96):
row = chr(65 + i // 12) # A-H
col = (i % 12) + 1 # 1-12
carrier[i] = YB_Pipette_Tip(f"{name}_tip_{row}{col}")
carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}")
return carrier

View File

@@ -15,7 +15,7 @@ def YB_jia_yang_tou_da(
height=height,
max_volume=max_volume,
barcode=barcode,
model="Solid_Stock",
model="YB_jia_yang_tou_da",
)
"""液1x1"""
@@ -33,7 +33,7 @@ def YB_ye_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="Liquid_Bottle",
model="YB_ye_Bottle",
)
"""100ml液体"""
@@ -51,7 +51,7 @@ def YB_ye_100ml_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="Liquid_Bottle_100ml",
model="YB_100ml_yeti",
)
"""高粘液"""
@@ -73,7 +73,7 @@ def YB_gao_nian_ye_Bottle(
)
"""5ml分液瓶"""
def YB_fen_ye_5ml_Bottle(
def YB_5ml_fenyeping(
name: str,
diameter: float = 20.0,
height: float = 50.0,
@@ -87,11 +87,11 @@ def YB_fen_ye_5ml_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="Separation_Bottle_5ml",
model="YB_5ml_fenyeping",
)
"""20ml分液瓶"""
def YB_fen_ye_20ml_Bottle(
def YB_20ml_fenyeping(
name: str,
diameter: float = 30.0,
height: float = 65.0,
@@ -105,7 +105,7 @@ def YB_fen_ye_20ml_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="Separation_Bottle_20ml",
model="YB_20ml_fenyeping",
)
"""配液瓶(小)"""
@@ -123,7 +123,7 @@ def YB_pei_ye_xiao_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="Mixing_Bottle_Small",
model="YB_pei_ye_xiao_Bottle",
)
"""配液瓶(大)"""
@@ -141,11 +141,11 @@ def YB_pei_ye_da_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="Mixing_Bottle_Large",
model="YB_pei_ye_da_Bottle",
)
"""枪头"""
def YB_Pipette_Tip(
def YB_qiang_tou(
name: str,
diameter: float = 10.0,
height: float = 50.0,
@@ -159,5 +159,5 @@ def YB_Pipette_Tip(
height=height,
max_volume=max_volume,
barcode=barcode,
model="Pipette_Tip",
model="YB_qiang_tou",
)

View File

@@ -1,4 +1,5 @@
from os import name
from pickle import TRUE
from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.resources.bioyond.YB_warehouses import bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x2, bioyond_warehouse_liquid_and_lid_handling, bioyond_warehouse_1x2x2, bioyond_warehouse_1x3x3, bioyond_warehouse_10x1x1, bioyond_warehouse_3x3x1, bioyond_warehouse_3x3x1_2, bioyond_warehouse_5x1x1, bioyond_warehouse_20x1x1, bioyond_warehouse_2x2x1, bioyond_warehouse_3x5x1
@@ -35,7 +36,6 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_PolymerPreparationStation_Deck(Deck):
def __init__(
self,
@@ -99,18 +99,18 @@ class BIOYOND_YB_Deck(Deck):
"自动堆栈-右": Coordinate(4160.0, 158.0, 0.0),
"手动堆栈-左": Coordinate(-400.0, 877.0, 0.0),
"手动堆栈-右": Coordinate(4160.0, 877.0, 0.0),
"粉末加样头堆栈": Coordinate(385.0, 1300.0, 0.0),
"配液站内试剂仓库": Coordinate(1164.0, 676.0, 0.0),
"试剂替换仓库": Coordinate(2717.0, 676.0, 0.0),
"粉末加样头堆栈": Coordinate(415.0, 1301.0, 0.0),
"配液站内试剂仓库": Coordinate(2162.0, 337.0, 0.0),
"试剂替换仓库": Coordinate(1173.0, 702.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
def YB_Deck(name: str) -> Deck:
by=BIOYOND_YB_Deck(name=name)
by.setup()
return by
# def YB_Deck(name: str) -> Deck:
# # by=BIOYOND_YB_Deck(name=name)
# # by.setup()
# return None

View File

@@ -29,7 +29,7 @@ class Bottle(Well):
size_x: float = 0.0,
size_y: float = 0.0,
size_z: float = 0.0,
barcode: Optional[str] = "",
barcode: Optional[str] = None,
category: str = "container",
model: Optional[str] = None,
**kwargs,

View File

@@ -6,11 +6,12 @@ from typing import Optional, Dict, Any, List
import rclpy
from unilabos_msgs.srv._serial_command import SerialCommand_Response
from unilabos.app.register import register_devices_and_resources
from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher
from unilabos_msgs.srv import SerialCommand # type: ignore
from rclpy.executors import MultiThreadedExecutor, SingleThreadedExecutor
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.timer import Timer
@@ -108,66 +109,51 @@ def slave(
rclpy_init_args: List[str] = ["--log-level", "debug"],
) -> None:
"""从节点函数"""
# 1. 初始化 ROS2
if not rclpy.ok():
rclpy.init(args=rclpy_init_args)
executor = rclpy.__executor
if not executor:
executor = rclpy.__executor = MultiThreadedExecutor()
devices_instances = {}
for device_config in devices_config.root_nodes:
device_id = device_config.res_content.id
if device_config.res_content.type != "device":
d = initialize_device_from_dict(device_id, device_config.get_nested_dict())
devices_instances[device_id] = d
# 默认初始化
# if d is not None and isinstance(d, Node):
# executor.add_node(d)
# else:
# print(f"Warning: Device {device_id} could not be initialized or is not a valid Node")
n = Node(f"slaveMachine_{BasicConfig.machine_name}", parameter_overrides=[])
executor.add_node(n)
if visual != "disable":
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
resource_mesh_manager = ResourceMeshManager(
resources_mesh_config,
resources_config, # type: ignore FIXME
resource_tracker=DeviceNodeResourceTracker(),
device_id="resource_mesh_manager",
)
joint_republisher = JointRepublisher("joint_republisher", DeviceNodeResourceTracker())
executor.add_node(resource_mesh_manager)
executor.add_node(joint_republisher)
# 1.5 启动 executor 线程
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")
thread.start()
# 2. 创建 Slave Machine Node
n = Node(f"slaveMachine_{BasicConfig.machine_name}", parameter_overrides=[])
executor.add_node(n)
# 3. 向 Host 报送节点信息和物料,获取 UUID 映射
uuid_mapping = {}
if not BasicConfig.slave_no_host:
# 3.1 报送节点信息
sclient = n.create_client(SerialCommand, "/node_info_update")
sclient.wait_for_service()
registry_config = {}
devices_to_register, resources_to_register = register_devices_and_resources(lab_registry, True)
registry_config.update(devices_to_register)
registry_config.update(resources_to_register)
request = SerialCommand.Request()
request.command = json.dumps(
{
"machine_name": BasicConfig.machine_name,
"type": "slave",
"devices_config": devices_config.dump(),
"registry_config": lab_registry.obtain_registry_device_info(),
"registry_config": registry_config,
},
ensure_ascii=False,
cls=TypeEncoder,
)
response = sclient.call_async(request).result()
sclient.call_async(request).result()
logger.info(f"Slave node info updated.")
# 使用新的 c2s_update_resource_tree 服务
# 3.2 报送物料树,获取 UUID 映射
if resources_config:
rclient = n.create_client(SerialCommand, "/c2s_update_resource_tree")
rclient.wait_for_service()
# 序列化 ResourceTreeSet 为 JSON
if resources_config:
request = SerialCommand.Request()
request.command = json.dumps(
{
@@ -180,35 +166,61 @@ def slave(
},
ensure_ascii=False,
)
tree_response: SerialCommand_Response = rclient.call_async(request).result()
tree_response: SerialCommand_Response = rclient.call(request)
uuid_mapping = json.loads(tree_response.response)
# 创建反向映射new_uuid -> old_uuid
reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()}
for node in resources_config.root_nodes:
if node.res_content.type == "device":
for sub_node in node.children:
# 只有二级子设备
if sub_node.res_content.type != "device":
device_tracker = devices_instances[node.res_content.id].resource_tracker
# sub_node.res_content.uuid 已经是新UUID需要用旧UUID去查找
old_uuid = reverse_uuid_mapping.get(sub_node.res_content.uuid)
if old_uuid:
# 找到旧UUID使用UUID查找
resource_instance = device_tracker.figure_resource({"uuid": old_uuid})
logger.info(f"Slave resource tree added. UUID mapping: {len(uuid_mapping)} nodes")
# 3.3 使用 UUID 映射更新 resources_config 的 UUID参考 client.py 逻辑)
old_uuids = {node.res_content.uuid: node for node in resources_config.all_nodes}
for old_uuid, node in old_uuids.items():
if old_uuid in uuid_mapping:
new_uuid = uuid_mapping[old_uuid]
node.res_content.uuid = new_uuid
# 更新所有子节点的 parent_uuid
for child in node.children:
child.res_content.parent_uuid = new_uuid
else:
# 未找到旧UUID使用name查找
resource_instance = device_tracker.figure_resource({"name": sub_node.res_content.name})
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
else:
logger.error("Slave模式不允许新增非设备节点下的物料")
continue
if tree_response:
logger.info(f"Slave resource tree added. Response: {tree_response.response}")
else:
logger.warning("Slave resource tree add response is None")
logger.warning(f"资源UUID未更新: {old_uuid}")
else:
logger.info("No resources to add.")
# 4. 初始化所有设备实例(此时 resources_config 的 UUID 已更新)
devices_instances = {}
for device_config in devices_config.root_nodes:
device_id = device_config.res_content.id
if device_config.res_content.type == "device":
d = initialize_device_from_dict(device_id, device_config.get_nested_dict())
if d is not None:
devices_instances[device_id] = d
logger.info(f"Device {device_id} initialized.")
else:
logger.warning(f"Device {device_id} initialization failed.")
# 5. 如果启用可视化,创建可视化相关节点
if visual != "disable":
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
resources_list = (
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
if resources_config
else []
)
resource_mesh_manager = ResourceMeshManager(
resources_mesh_config,
resources_list,
resource_tracker=DeviceNodeResourceTracker(),
device_id="resource_mesh_manager",
)
joint_republisher = JointRepublisher("joint_republisher", DeviceNodeResourceTracker())
lh_joint_pub = LiquidHandlerJointPublisher(
resources_config=resources_list, resource_tracker=DeviceNodeResourceTracker()
)
executor.add_node(resource_mesh_manager)
executor.add_node(joint_republisher)
executor.add_node(lh_joint_pub)
# 7. 保持运行
while True:
time.sleep(1)

View File

@@ -53,7 +53,7 @@ from unilabos.ros.nodes.resource_tracker import (
)
from unilabos.ros.x.rclpyx import get_event_loop
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
from unilabos.utils.async_util import run_async_func
from rclpy.task import Task, Future
from unilabos.utils.import_manager import default_manager
from unilabos.utils.log import info, debug, warning, error, critical, logger, trace
from unilabos.utils.type_check import get_type_class, TypeEncoder, get_result_info_str
@@ -555,6 +555,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
rclpy.get_global_executor().add_node(self)
self.lab_logger().debug(f"ROS节点初始化完成")
async def sleep(self, rel_time: float, callback_group=None):
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:
return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs)
async def update_resource(self, resources: List["ResourcePLR"]):
r = SerialCommand.Request()
tree_set = ResourceTreeSet.from_plr_resources(resources)
@@ -629,6 +638,145 @@ class BaseROS2DeviceNode(Node, Generic[T]):
- remove: 从资源树中移除资源
"""
from pylabrobot.resources.resource import Resource as ResourcePLR
def _handle_add(
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
) -> Dict[str, Any]:
"""
处理资源添加操作的内部函数
Args:
plr_resources: PLR资源列表
tree_set: 资源树集合
additional_add_params: 额外的添加参数
Returns:
操作结果字典
"""
for plr_resource, tree in zip(plr_resources, tree_set.trees):
self.resource_tracker.add_resource(plr_resource)
self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
func = getattr(self.driver_instance, "resource_tree_add", None)
if callable(func):
func(plr_resources)
return {"success": True, "action": "add"}
def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]:
"""
处理资源移除操作的内部函数
Args:
resources_uuid: 要移除的资源UUID列表
Returns:
操作结果字典,包含移除的资源列表
"""
found_resources: List[List[Union[ResourcePLR, dict]]] = self.resource_tracker.figure_resource(
[{"uuid": uid} for uid in resources_uuid], try_mode=True
)
found_plr_resources = []
other_plr_resources = []
for found_resource in found_resources:
for resource in found_resource:
if issubclass(resource.__class__, ResourcePLR):
found_plr_resources.append(resource)
else:
other_plr_resources.append(resource)
# 调用driver的remove回调
func = getattr(self.driver_instance, "resource_tree_remove", None)
if callable(func):
func(found_plr_resources)
# 从parent卸载并从tracker移除
for plr_resource in found_plr_resources:
if plr_resource.parent is not None:
plr_resource.parent.unassign_child_resource(plr_resource)
self.resource_tracker.remove_resource(plr_resource)
self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点")
for other_plr_resource in other_plr_resources:
self.resource_tracker.remove_resource(other_plr_resource)
self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点")
return {
"success": True,
"action": "remove",
# "removed_plr": found_plr_resources,
# "removed_other": other_plr_resources,
}
def _handle_update(
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
) -> Dict[str, Any]:
"""
处理资源更新操作的内部函数
Args:
plr_resources: PLR资源列表包含新状态
tree_set: 资源树集合
additional_add_params: 额外的参数
Returns:
操作结果字典
"""
for plr_resource, tree in zip(plr_resources, tree_set.trees):
states = plr_resource.serialize_all_state()
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
{"uuid": tree.root_node.res_content.uuid}, try_mode=False
)
# Update操作中包含改名需要先remove再add
if original_instance.name != plr_resource.name:
old_name = original_instance.name
new_name = plr_resource.name
self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}")
# 收集所有相关的uuid包括子节点
_handle_remove([original_instance.unilabos_uuid])
original_instance.name = new_name
_handle_add([original_instance], tree_set, additional_add_params)
self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}")
# 常规更新:不涉及改名
original_parent_resource = original_instance.parent
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
self.lab_logger().info(
f"物料{original_instance} 原始父节点{original_parent_resource_uuid} "
f"目标父节点{target_parent_resource_uuid} 更新"
)
# 更新extra
if getattr(plr_resource, "unilabos_extra", None) is not None:
original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501
# 如果父节点变化,需要重新挂载
if (
original_parent_resource_uuid != target_parent_resource_uuid
and original_parent_resource is not None
):
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
# 加载状态
original_instance.load_all_state(states)
child_count = len(original_instance.get_all_children())
self.lab_logger().info(
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}"
)
# 调用driver的update回调
func = getattr(self.driver_instance, "resource_tree_update", None)
if callable(func):
func(plr_resources)
return {"success": True, "action": "update"}
try:
data = json.loads(req.command)
results = []
@@ -647,7 +795,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
].call_async(
SerialCommand.Request(
command=json.dumps(
{"data": {"data": resources_uuid, "with_children": False}, "action": "get"}
{"data": {"data": resources_uuid, "with_children": True if action == "add" else False}, "action": "get"}
)
)
) # type: ignore
@@ -655,68 +803,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
tree_set = ResourceTreeSet.from_raw_list(raw_nodes)
try:
if action == "add":
# 添加资源到资源跟踪器
if tree_set is None:
raise ValueError("tree_set不能为None")
plr_resources = tree_set.to_plr_resources()
for plr_resource, tree in zip(plr_resources, tree_set.trees):
self.resource_tracker.add_resource(plr_resource)
self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
func = getattr(self.driver_instance, "resource_tree_add", None)
if callable(func):
func(plr_resources)
results.append({"success": True, "action": "add"})
result = _handle_add(plr_resources, tree_set, additional_add_params)
results.append(result)
elif action == "update":
# 更新资源
if tree_set is None:
raise ValueError("tree_set不能为None")
plr_resources = tree_set.to_plr_resources()
for plr_resource, tree in zip(plr_resources, tree_set.trees):
states = plr_resource.serialize_all_state()
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
{"uuid": tree.root_node.res_content.uuid}, try_mode=False
)
original_parent_resource = original_instance.parent
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
self.lab_logger().info(
f"物料{original_instance} 原始父节点{original_parent_resource_uuid} 目标父节点{target_parent_resource_uuid} 更新"
)
# todo: 对extra进行update
if getattr(plr_resource, "unilabos_extra", None) is not None:
original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra")
if original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None:
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
original_instance.load_all_state(states)
self.lab_logger().info(
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] 及其子节点 {len(original_instance.get_all_children())}"
)
func = getattr(self.driver_instance, "resource_tree_update", None)
if callable(func):
func(plr_resources)
results.append({"success": True, "action": "update"})
result = _handle_update(plr_resources, tree_set, additional_add_params)
results.append(result)
elif action == "remove":
# 移除资源
found_resources: List[List[Union[ResourcePLR, dict]]] = self.resource_tracker.figure_resource(
[{"uuid": uid} for uid in resources_uuid], try_mode=True
)
found_plr_resources = []
other_plr_resources = []
for found_resource in found_resources:
for resource in found_resource:
if issubclass(resource.__class__, ResourcePLR):
found_plr_resources.append(resource)
else:
other_plr_resources.append(resource)
func = getattr(self.driver_instance, "resource_tree_remove", None)
if callable(func):
func(found_plr_resources)
for plr_resource in found_plr_resources:
if plr_resource.parent is not None:
plr_resource.parent.unassign_child_resource(plr_resource)
self.resource_tracker.remove_resource(plr_resource)
self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点")
for other_plr_resource in other_plr_resources:
self.resource_tracker.remove_resource(other_plr_resource)
self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点")
results.append({"success": True, "action": "remove"})
result = _handle_remove(resources_uuid)
results.append(result)
except Exception as e:
error_msg = f"Error processing {action} operation: {str(e)}"
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
@@ -725,7 +825,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 返回处理结果
result_json = {"results": results, "total": len(data)}
res.response = json.dumps(result_json, ensure_ascii=False)
res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder)
self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
except json.JSONDecodeError as e:
@@ -995,9 +1095,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 通过资源跟踪器获取本地实例
final_resources = queried_resources if is_sequence else queried_resources[0]
final_resources = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False) if not is_sequence else [
self.resource_tracker.figure_resource({"name": res.name}, try_mode=False) for res in queried_resources
final_resources = (
self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False)
if not is_sequence
else [
self.resource_tracker.figure_resource({"name": res.name}, try_mode=False)
for res in queried_resources
]
)
action_kwargs[k] = final_resources
except Exception as e:
@@ -1218,6 +1323,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise JsonCommandInitError(
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
)
def _convert_resource_sync(self, resource_data: Dict[str, Any]):
"""同步转换资源数据为实例"""
# 创建资源查询请求
@@ -1385,18 +1491,27 @@ class ROS2DeviceNode:
它不继承设备类,而是通过代理模式访问设备类的属性和方法。
"""
# 类变量,用于循环管理
_loop = None
_loop_running = False
_loop_thread = None
@classmethod
def run_async_func(cls, func, trace_error=True, **kwargs) -> Task:
def _handle_future_exception(fut):
try:
fut.result()
except Exception as e:
error(f"异步任务 {func.__name__} 报错了")
error(traceback.format_exc())
future = rclpy.get_global_executor().create_task(func(**kwargs))
if trace_error:
future.add_done_callback(_handle_future_exception)
return future
@classmethod
def get_loop(cls):
return cls._loop
@classmethod
def run_async_func(cls, func, trace_error=True, **kwargs):
return run_async_func(func, loop=cls._loop, trace_error=trace_error, **kwargs)
async def async_wait_for(cls, node: Node, wait_time: float, callback_group=None):
future = Future()
timer = node.create_timer(wait_time, lambda : future.set_result(None), callback_group=callback_group, clock=node.get_clock())
await future
timer.cancel()
node.destroy_timer(timer)
@property
def driver_instance(self):
@@ -1436,11 +1551,6 @@ class ROS2DeviceNode:
print_publish: 是否打印发布信息
driver_is_ros:
"""
# 在初始化时检查循环状态
if ROS2DeviceNode._loop_running and ROS2DeviceNode._loop_thread is not None:
pass
elif ROS2DeviceNode._loop_thread is None:
self._start_loop()
# 保存设备类是否支持异步上下文
self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__")
@@ -1529,17 +1639,6 @@ class ROS2DeviceNode:
except Exception as e:
self._ros_node.lab_logger().error(f"设备后初始化失败: {e}")
def _start_loop(self):
def run_event_loop():
loop = asyncio.new_event_loop()
ROS2DeviceNode._loop = loop
asyncio.set_event_loop(loop)
loop.run_forever()
ROS2DeviceNode._loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNodeLoop")
ROS2DeviceNode._loop_thread.start()
logger.info(f"循环线程已启动")
class DeviceInfoType(TypedDict):
id: str

View File

@@ -18,7 +18,8 @@ from unilabos_msgs.srv import (
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand, ResourceGet,
SerialCommand,
ResourceGet,
) # type: ignore
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID
@@ -164,29 +165,16 @@ class HostNode(BaseROS2DeviceNode):
# resources_config 的 root node 是
# # 创建反向映射new_uuid -> old_uuid
# reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()}
# for tree in resources_config.trees:
# node = tree.root_node
# if node.res_content.type == "device":
# if node.res_content.id == "host_node":
# continue
# # slave节点走c2s更新接口拿到add自行update uuid
# device_tracker = self.devices_instances[node.res_content.id].resource_tracker
# old_uuid = reverse_uuid_mapping.get(node.res_content.uuid)
# if old_uuid:
# # 找到旧UUID使用UUID查找
# resource_instance = device_tracker.uuid_to_resources.get(old_uuid)
# else:
# # 未找到旧UUID使用name查找
# resource_instance = device_tracker.figure_resource(
# {"name": node.res_content.name}
# )
# device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
# else:
# try:
# for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
# self.resource_tracker.add_resource(plr_resource)
# except Exception as ex:
# self.lab_logger().warning("[Host Node-Resource] 根节点物料序列化失败!")
for tree in resources_config.trees:
node = tree.root_node
if node.res_content.type == "device":
continue
else:
try:
for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
self._resource_tracker.add_resource(plr_resource)
except Exception as ex:
self.lab_logger().warning(f"[Host Node-Resource] 根节点物料{tree}序列化失败!")
except Exception as ex:
logger.error(f"[Host Node-Resource] 添加物料出错!\n{traceback.format_exc()}")
# 初始化Node基类传递空参数覆盖列表
@@ -664,7 +652,7 @@ class HostNode(BaseROS2DeviceNode):
if bCreate:
self.lab_logger().trace(f"Status created: {device_id}.{property_name} = {msg.data}")
else:
self.lab_logger().debug(f"Status updated: {device_id}.{property_name} = {msg.data}")
self.lab_logger().trace(f"Status updated: {device_id}.{property_name} = {msg.data}")
def send_goal(
self,
@@ -877,11 +865,10 @@ class HostNode(BaseROS2DeviceNode):
success = False
uuid_mapping = {}
if len(self.bridges) > 0:
from unilabos.app.web.client import HTTPClient
from unilabos.app.web.client import HTTPClient, http_client
client: HTTPClient = self.bridges[-1]
resource_start_time = time.time()
uuid_mapping = client.resource_tree_add(resource_tree_set, mount_uuid, first_add)
uuid_mapping = http_client.resource_tree_add(resource_tree_set, mount_uuid, first_add)
success = True
resource_end_time = time.time()
self.lab_logger().info(
@@ -989,9 +976,10 @@ class HostNode(BaseROS2DeviceNode):
"""
更新节点信息回调
"""
self.lab_logger().info(f"[Host Node] Node info update request received: {request}")
# self.lab_logger().info(f"[Host Node] Node info update request received: {request}")
try:
from unilabos.app.communication import get_communication_client
from unilabos.app.web.client import HTTPClient, http_client
info = json.loads(request.command)
if "SYNC_SLAVE_NODE_INFO" in info:
@@ -1000,10 +988,10 @@ class HostNode(BaseROS2DeviceNode):
edge_device_id = info["edge_device_id"]
self.device_machine_names[edge_device_id] = machine_name
else:
comm_client = get_communication_client()
registry_config = info["registry_config"]
for device_config in registry_config:
comm_client.publish_registry(device_config["id"], device_config)
devices_config = info.pop("devices_config")
registry_config = info.pop("registry_config")
if registry_config:
http_client.resource_registry({"resources": registry_config})
self.lab_logger().debug(f"[Host Node] Node info update: {info}")
response.response = "OK"
except Exception as e:
@@ -1029,10 +1017,9 @@ class HostNode(BaseROS2DeviceNode):
success = False
if len(self.bridges) > 0: # 边的提交待定
from unilabos.app.web.client import HTTPClient
from unilabos.app.web.client import HTTPClient, http_client
client: HTTPClient = self.bridges[-1]
r = client.resource_add(add_schema(resources))
r = http_client.resource_add(add_schema(resources))
success = bool(r)
response.success = success

View File

@@ -2,7 +2,7 @@ import traceback
import uuid
from pydantic import BaseModel, field_serializer, field_validator
from pydantic import Field
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
from unilabos.utils.log import logger
@@ -894,7 +894,7 @@ class DeviceNodeResourceTracker(object):
new_uuid = name_to_uuid_map[resource_name]
self.set_resource_uuid(res, new_uuid)
self.uuid_to_resources[new_uuid] = res
logger.debug(f"设置资源UUID: {resource_name} -> {new_uuid}")
logger.trace(f"设置资源UUID: {resource_name} -> {new_uuid}")
return 1
return 0
@@ -917,6 +917,7 @@ class DeviceNodeResourceTracker(object):
if resource_name and resource_name in name_to_extra_map:
extra = name_to_extra_map[resource_name]
self.set_resource_extra(res, extra)
if len(extra):
logger.debug(f"设置资源Extra: {resource_name} -> {extra}")
return 1
return 0
@@ -927,7 +928,7 @@ class DeviceNodeResourceTracker(object):
"""
递归遍历资源树更新所有节点的uuid
Args:0
Args:
resource: 资源对象可以是dict或实例
uuid_map: uuid映射字典{old_uuid: new_uuid}
@@ -952,6 +953,27 @@ class DeviceNodeResourceTracker(object):
return self._traverse_and_process(resource, process)
def loop_gather_uuid(self, resource) -> List[str]:
"""
递归遍历资源树收集所有节点的uuid
Args:
resource: 资源对象可以是dict或实例
Returns:
收集到的uuid列表
"""
uuid_list = []
def process(res):
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
if current_uuid:
uuid_list.append(current_uuid)
return 0
self._traverse_and_process(resource, process)
return uuid_list
def _collect_uuid_mapping(self, resource):
"""
递归收集资源的 uuid 映射到 uuid_to_resources
@@ -965,14 +987,15 @@ class DeviceNodeResourceTracker(object):
if current_uuid:
old = self.uuid_to_resources.get(current_uuid)
self.uuid_to_resources[current_uuid] = res
logger.debug(
logger.trace(
f"收集资源UUID映射: {current_uuid} -> {res} {'' if old is None else f'(覆盖旧值: {old})'}"
)
return 1
return 0
self._traverse_and_process(resource, process)
def _remove_uuid_mapping(self, resource):
def _remove_uuid_mapping(self, resource) -> int:
"""
递归清除资源的 uuid 映射
@@ -984,10 +1007,11 @@ class DeviceNodeResourceTracker(object):
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
if current_uuid and current_uuid in self.uuid_to_resources:
self.uuid_to_resources.pop(current_uuid)
logger.debug(f"移除资源UUID映射: {current_uuid} -> {res}")
logger.trace(f"移除资源UUID映射: {current_uuid} -> {res}")
return 1
return 0
self._traverse_and_process(resource, process)
return self._traverse_and_process(resource, process)
def parent_resource(self, resource):
if id(resource) in self.resource2parent_resource:
@@ -1042,13 +1066,12 @@ class DeviceNodeResourceTracker(object):
removed = True
break
if not removed:
# 递归清除uuid映射
count = self._remove_uuid_mapping(resource)
if not count:
logger.warning(f"尝试移除不存在的资源: {resource}")
return False
# 递归清除uuid映射
self._remove_uuid_mapping(resource)
# 清除 resource2parent_resource 中与该资源相关的映射
# 需要清除1) 该资源作为 key 的映射 2) 该资源作为 value 的映射
keys_to_remove = []
@@ -1071,7 +1094,9 @@ class DeviceNodeResourceTracker(object):
self.uuid_to_resources.clear()
self.resource2parent_resource.clear()
def figure_resource(self, query_resource, try_mode=False):
def figure_resource(
self, query_resource: Union[List[Union[dict, "PLRResource"]], dict, "PLRResource"], try_mode=False
) -> Union[List[Union[dict, "PLRResource", List[Union[dict, "PLRResource"]]]], dict, "PLRResource"]:
if isinstance(query_resource, list):
return [self.figure_resource(r, try_mode) for r in query_resource]
elif (

View File

@@ -1,22 +0,0 @@
import asyncio
import traceback
from asyncio import get_event_loop
from unilabos.utils.log import error
def run_async_func(func, *, loop=None, trace_error=True, **kwargs):
if loop is None:
loop = get_event_loop()
def _handle_future_exception(fut):
try:
fut.result()
except Exception as e:
error(f"异步任务 {func.__name__} 报错了")
error(traceback.format_exc())
future = asyncio.run_coroutine_threadsafe(func(**kwargs), loop)
if trace_error:
future.add_done_callback(_handle_future_exception)
return future

View File

@@ -192,6 +192,18 @@ def configure_logger(loglevel=None):
# 添加处理器到根日志记录器
root_logger.addHandler(console_handler)
# 降低第三方库的日志级别,避免过多输出
# pymodbus 库的日志太详细,设置为 WARNING
logging.getLogger('pymodbus').setLevel(logging.WARNING)
logging.getLogger('pymodbus.logging').setLevel(logging.WARNING)
logging.getLogger('pymodbus.logging.base').setLevel(logging.WARNING)
logging.getLogger('pymodbus.logging.decoders').setLevel(logging.WARNING)
# websockets 库的日志输出较多,设置为 WARNING
logging.getLogger('websockets').setLevel(logging.WARNING)
logging.getLogger('websockets.client').setLevel(logging.WARNING)
logging.getLogger('websockets.server').setLevel(logging.WARNING)
# 配置日志系统
configure_logger()