Compare commits

..

527 Commits

Author SHA1 Message Date
Andy6M
3e43359460 fix(bioyond): fix order name type and prep bottle max volumes
bioyond_cell: Ensure order_name is cast to str and fix mix_time handling for single int/float values. YB_bottles: Fix max_volume capacity for 15mL and 60mL prep bottles to match their names.
2026-04-16 21:17:22 +08:00
Andy6M
73add2dc06 feat: implement electrolyte CSV export and barcode tracking
- add CSV export for order data in bioyond_cell
- extract prep and vial bottles from order_finish report
- update bioyond_cell registry with csv_export_path
- update coin_cell_assembly to export new bottle barcodes and mass ratios
- add 260415csv_export_walkthrough.md
2026-04-15 12:07:01 +08:00
Andy6M
dd21d93151 chore: remove local-only date CSV files (not for upstream) 2026-04-10 18:06:58 +08:00
Andy6M
e11c3533c7 Merge origin/dev into backup/local-0.10.18-20260324 2026-04-09 18:21:14 +08:00
Xuwznln
58997f0654 fix create_resource_with_slot 2026-04-09 17:34:25 +08:00
Xuwznln
fbfc3e30fb update unilabos_formulation & batch-submit-exp 2026-04-09 16:40:31 +08:00
Andy6M
ed952e8a44 feat: 更新Neware电池测试系统驱动及电芯组装工作站相关文件
- 更新 neware_battery_test_system 驱动及设备配置
- 新增 generate_xml_content.py 工具脚本
- 更新 bioyond_cell_workstation 工作站实现
- 更新 coin_cell_assembly 扣式电池组装逻辑
- 更新相关注册表 YAML 配置:neware_battery_test_system、coin_cell_workstation、bioyond_cell
2026-04-09 14:16:49 +08:00
Xuwznln
1d1c1367df scale multi exec thread up to 48 2026-04-09 14:15:38 +08:00
Xuwznln
c91b600e90 update handle creation api 2026-04-02 22:53:31 +08:00
Xuwznln
49b3c850f9 fit cocurrent gap 2026-04-02 16:01:23 +08:00
Xuwznln
25c94af755 add running status debounce 2026-04-01 16:01:22 +08:00
Xuwznln
861a012747 allow non @topic_config support 2026-03-31 13:15:06 +08:00
Andy6M
467f0b1115 feat: update coin cell assembly, bioyond cell workstation, and resource configs 2026-03-25 23:31:06 +08:00
Xuwznln
ee63e95f50 update skill 2026-03-25 23:20:13 +08:00
Xuwznln
dbf5df6e4d add placeholder keys 2026-03-25 12:11:10 +08:00
Xuwznln
f10c0343ce add placeholder keys 2026-03-25 11:56:40 +08:00
Xuwznln
8b6553bdd9 always free 2026-03-25 11:24:19 +08:00
Xuwznln
e7a4afd6b5 提交实验技能 2026-03-25 00:42:28 +08:00
Xuwznln
f18f6d82fc disable samples 2026-03-24 23:45:50 +08:00
Xuwznln
b7c726635c correct sample demo ret value 2026-03-24 23:24:12 +08:00
Xuwznln
c809912fd3 新增试剂reagent 2026-03-24 23:22:45 +08:00
Xuwznln
d956b27e9f update registry 2026-03-24 23:10:57 +08:00
Xuwznln
ff1e21fcd8 新增manual_confirm 2026-03-24 23:04:00 +08:00
Xuwznln
b9d9666003 add workstation creation skill 2026-03-24 23:03:49 +08:00
Andy6M
91928a87ac Merge remote-tracking branch 'origin/dev' into backup/local-0.10.18-20260324 2026-03-24 12:26:03 +08:00
Junhan Chang
d7850b050b add create_orders_foumulation and extract common code 2026-03-24 11:15:54 +08:00
Junhan Chang
dff70bd72b add formulation action 2026-03-24 11:12:05 +08:00
Junhan Chang
03e3719b18 add ai conventions 2026-03-24 11:08:49 +08:00
Andy6M
41a018febc chore: 鏈湴淇敼瀛樻。 - 0.10.18 鍩虹鐗堟湰澶囦唤 (2026-03-24)
Made-with: Cursor
2026-03-24 10:54:59 +08:00
Junhan Chang
d776550a4b add virtual_sample_demo 样品追踪测试设备 2026-03-23 16:43:20 +08:00
Xuwznln
3d8123849a add external devices param
fix registry upload missing type
2026-03-23 15:01:16 +08:00
Xuwznln
d2f204c5b0 bump to 0.10.19 2026-03-22 04:17:21 +08:00
Xuwznln
d8922884b1 fast registry load 2026-03-22 04:14:47 +08:00
Xuwznln
427afe83d4 minor fix on skill & registry 2026-03-22 03:36:28 +08:00
Xuwznln
23c2e3b2f7 stripe ros2 schema desc
add create-device-skill
2026-03-22 03:21:13 +08:00
Xuwznln
59c26265e9 new registry system backwards to yaml 2026-03-22 02:19:54 +08:00
Xuwznln
4c2adea55a remove not exist resource 2026-03-21 23:35:51 +08:00
Xuwznln
0f6264503a new registry sys
exp. support with add device
2026-03-21 19:26:24 +08:00
Junhan Chang
2c554182d3 add ai conventions 2026-03-19 14:14:40 +08:00
Andy6M
7505e024f3 fix: 物料系统标准化重构 + 多轮运行期 Bug 修复 (2026-03-12)
- MagazineHolder: klasses=None,解耦极片子节点初始化
- Magazine: 重写 serialize/deserialize,截断旧极片脏数据
- bottle_carriers: 移除 YIHUA_Electrolyte_12VialCarrier 初始化填瓶
- decks.py: BIOYOND_YB_Deck→BioyondElectrolyteDeck,移除 setup 参数
- YB_YH_materials.py: CoincellDeck→YihuaCoinCellDeck,新增 electrolyte_buffer 槽位
- resource_tracker.py: Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重
- itemized_carrier.py: XY 近似坐标匹配,修复 Z 偏移问题
- bioyond_cell_workstation.py: 跨站转运改用真实资源 + 类型映射双模式查找
- station.py: sync_to_external 属性访问路径修复
- coin_cell_assembly.py: 新增 10 个 Modbus 余量属性
- CSV/JSON/YAML 配置同步更新(类名重命名 + 移除 setup)
- 新增 changelog_2026-03-12.md
2026-03-19 00:41:26 +08:00
Xuwznln
6d319d91ff correct raise create resource error 2026-03-10 16:26:37 +08:00
Xuwznln
3155b2f97e ret info fix revert 2026-03-10 16:04:27 +08:00
Xuwznln
e5e30a1c7d ret info fix 2026-03-10 16:00:24 +08:00
Xuwznln
4e82f62327 fix prcxi check 2026-03-10 15:57:27 +08:00
Xuwznln
95d3456214 add create_resource schema 2026-03-10 15:27:39 +08:00
Xuwznln
38bf95b13c re signal host ready event 2026-03-10 14:13:06 +08:00
Xuwznln
f2c0bec02c add websocket connection timeout and improve reconnection logic
add open_timeout parameter to websocket connection
add TimeoutError and InvalidStatus exception handling
implement exponential backoff for reconnection attempts
simplify reconnection logic flow
2026-03-07 04:40:56 +08:00
Xuwznln
e0394bf414 Merge remote-tracking branch 'origin/dev' into dev 2026-03-04 19:18:55 +08:00
Xuwznln
975a56415a import gzip 2026-03-04 19:18:36 +08:00
Xuwznln
cadbe87e3f add gzip 2026-03-04 19:18:19 +08:00
Xuwznln
b993c1f590 add gzip 2026-03-04 19:18:09 +08:00
Xuwznln
e0fae94c10 change pose extra to any 2026-03-04 19:06:58 +08:00
Xuwznln
b5cd181ac1 add isFlapY 2026-03-04 18:59:45 +08:00
Xuwznln
5c047beb83 support container as example
add z index

(cherry picked from commit 145fcaae65)
2026-03-03 18:04:13 +08:00
Xuwznln
b40c087143 fix container volume 2026-03-03 17:13:32 +08:00
Xuwznln
7f1cc3b2a5 update materials 2026-03-03 11:43:52 +08:00
Xuwznln
3f160c2049 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:23 +08:00
Xuwznln
a54e7c0f23 new workflow & prcxi slot removal 2026-03-02 18:29:25 +08:00
Xuwznln
e5015cd5e0 fix size change 2026-03-02 15:52:44 +08:00
Xuwznln
514373c164 v0.10.18
(cherry picked from commit 06b6f0d804)
2026-03-02 02:30:10 +08:00
Xuwznln
fcea02585a no opcua installation on macos 2026-02-28 09:41:37 +08:00
Xuwznln
07cf690897 fix possible crash 2026-02-12 01:46:26 +08:00
Xuwznln
cfea27460a fix deck & host_node 2026-02-12 01:46:24 +08:00
Xuwznln
b7d3e980a9 set liquid with tube 2026-02-12 01:46:23 +08:00
Xuwznln
f9ed6cb3fb add test_resource_schema 2026-02-11 14:02:21 +08:00
Xuwznln
699a0b3ce7 fix test resource schema 2026-02-10 23:08:29 +08:00
Xuwznln
cf3a20ae79 registry update & workflow update 2026-02-10 22:46:07 +08:00
Xuwznln
cdf0652020 add test mode 2026-02-10 15:18:41 +08:00
Xuwznln
60073ff139 support description & tags upload 2026-02-10 14:38:55 +08:00
Xuwznln
a9053b822f fix config load 2026-02-10 13:06:05 +08:00
Xuwznln
d238c2ab8b fix log 2026-02-10 13:04:33 +08:00
Xuwznln
9a7d5c7c82 add registry name & add always free 2026-02-07 02:11:43 +08:00
Xuwznln
4f7d431c0b correct config organic synthesis 2026-02-06 12:04:19 +08:00
Xuwznln
341a1b537c Adapt to new scheduler, sampels, and edge upload format (#230)
* add sample_material

* adapt to new samples sys

* fix pump transfer. fix resource update when protocol & ros callback

* Adapt to new scheduler.
2026-02-06 00:49:53 +08:00
Xuwznln
957fb41a6f Feat/samples (#229)
* add sample_material

* adapt to new samples sys
2026-02-05 00:42:12 +08:00
Xuwznln
26271bcab8 adapt to new samples sys 2026-02-04 18:49:08 +08:00
Xuwznln
84a8223173 adapt to new edge format 2026-02-03 23:22:38 +08:00
Xuwznln
e8d1263488 workflow upload & prcxi transfer liquid 2026-02-03 18:10:32 +08:00
Xuwznln
380b39100d lh liquid 2026-02-03 15:15:57 +08:00
Xuwznln
56eb7e2ab4 speed up registry load 2026-02-02 20:01:04 +08:00
Xuwznln
23ce145f74 workflow upload & set liquid fix & add set liquid with plate 2026-02-02 18:23:33 +08:00
Xuwznln
b0da149252 fix upload workflow json 2026-02-02 17:19:07 +08:00
Xuwznln
07c9e6f0fe save class name when deserialize & protocol execute test 2026-02-02 16:05:17 +08:00
Xuwznln
ccec6b9d77 Support root node change pos 2026-02-02 12:03:19 +08:00
hanhua@dp.tech
dadfdf3d8d add unilabos_class 2026-01-30 18:07:53 +08:00
Xuwznln
400bb073d4 gather query 2026-01-28 13:23:25 +08:00
Xuwznln
3f63c36505 transfer liquid handles 2026-01-28 11:45:45 +08:00
Xuwznln
0ae94f7f3c add msg goal 2026-01-28 09:21:43 +08:00
Xuwznln
7eacae6442 Fix OT2 & ReAdd Virtual Devices 2026-01-28 01:05:32 +08:00
Xuwznln
f7d2cb4b9e CI Check use production mode 2026-01-27 19:59:06 +08:00
Xuwznln
bf980d7248 v0.10.17
(cherry picked from commit 176de521b4)
2026-01-27 19:41:49 +08:00
Xuwznln
27c0544bfc Fix Build 13 2026-01-27 19:36:42 +08:00
Xuwznln
d48e77c9ae Fix Build 12 2026-01-27 19:16:21 +08:00
Xuwznln
e70a5bea66 Fix Build 11 2026-01-27 19:09:39 +08:00
Xuwznln
467d75dc03 Fix Build 10 2026-01-27 17:41:06 +08:00
Xuwznln
9feeb0c430 Fix Build 9 2026-01-27 15:51:40 +08:00
Xuwznln
b2f26ffb28 Fix Build 8 2026-01-27 15:39:15 +08:00
dependabot[bot]
4b0d1553e9 ci(deps): bump actions/checkout from 4 to 6 (#223)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 15:30:47 +08:00
dependabot[bot]
67ddee2ab2 ci(deps): bump actions/upload-pages-artifact from 3 to 4 (#225)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 15:30:38 +08:00
dependabot[bot]
1bcdad9448 ci(deps): bump actions/upload-artifact from 4 to 6 (#224)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 15:30:31 +08:00
dependabot[bot]
039c96fe01 ci(deps): bump actions/configure-pages from 4 to 5 (#222)
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 15:30:22 +08:00
Xuwznln
e1555d10a0 Fix Build 7 2026-01-27 15:14:31 +08:00
Xuwznln
f2a96b2041 Fix Build 6 2026-01-27 14:36:35 +08:00
Xuwznln
329349639e Fix Build 5 2026-01-27 14:25:34 +08:00
Xuwznln
e4cc111523 Fix Build 4 2026-01-27 14:19:56 +08:00
Xuwznln
d245ceef1b Fix Build 3 2026-01-27 14:15:16 +08:00
Xuwznln
6db7fbd721 Fix Build 2 2026-01-27 13:45:32 +08:00
Xuwznln
ab05b858e1 Fix Build 1 2026-01-27 13:35:35 +08:00
Xuwznln
43e4c71a8e Update to ROS2 Humble 0.7 2026-01-27 13:31:24 +08:00
Xuwznln
2cf58ca452 Upgrade to py 3.11.14; ros 0.7; unilabos 0.10.16 2026-01-26 16:47:54 +08:00
Xuwznln
fd73bb7dcb CI Check Fix 5 2026-01-26 08:47:27 +08:00
Xuwznln
a02cecfd18 CI Check Fix 4 2026-01-26 08:20:17 +08:00
Xuwznln
d6accc3f1c CI Check Fix 3 2026-01-26 08:14:21 +08:00
Xuwznln
39dc443399 CI Check Fix 2 2026-01-26 02:23:40 +08:00
Xuwznln
37b1fca962 CI Check Fix 1 2026-01-26 02:22:21 +08:00
Xuwznln
216f19fb62 Workbench example, adjust log level, and ci check (#220)
* TestLatency Return Value Example & gitignore update

* Adjust log level & Add workbench virtual example & Add not action decorator & Add check_mode &

* Add CI Check
2026-01-26 02:15:13 +08:00
Xuwznln
ec7ca6a1fe Fix/workstation yb revision (#217)
* Revert log change & update registry

* Revert opcua client & move electrolyte node
2026-01-17 16:50:20 +08:00
Xuwznln
4c8022ee95 Workstation yb merge dev ready 260113 (#216)
* feat(bioyond): 添加计算实验设计功能,支持化合物配比和滴定比例参数

* feat(bioyond): 添加测量小瓶功能,支持基本参数配置

* feat(bioyond): 添加测量小瓶配置,支持新设备参数

* feat(bioyond): 更新仓库布局和尺寸,支持竖向排列的测量小瓶和试剂存放堆栈

* feat(bioyond): 优化任务创建流程,确保无论成功与否都清理任务队列以避免重复累积

* feat(bioyond): 添加设置反应器温度功能,支持温度范围和异常处理

* feat(bioyond): 调整反应器位置配置,统一坐标格式

* feat(bioyond): 添加调度器启动功能,支持任务队列执行并处理异常

* feat(bioyond): 优化调度器启动功能,添加异常处理并更新相关配置

* feat(opcua): 增强节点ID解析兼容性和数据类型处理

改进节点ID解析逻辑以支持多种格式,包括字符串和数字标识符
添加数据类型转换处理,确保写入值时类型匹配
优化错误提示信息,便于调试节点连接问题

* feat(registry): 新增后处理站的设备配置文件

添加后处理站的YAML配置文件,包含动作映射、状态类型和设备描述

* 添加调度器启动功能,合并物料参数配置,优化物料参数处理逻辑

* 添加从 Bioyond 系统自动同步工作流序列的功能,并更新相关配置

* fix:兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property

* fix:同步工作流序列

* feat: remove commented workflow synchronization from `reaction_station.py`.

* 添加时间约束功能及相关配置

* fix:自动更新物料缓存功能,添加物料时更新缓存并在删除时移除缓存项

* fix:在添加物料时处理字符串和字典返回值,确保正确更新缓存

* fix:更新奔曜错误处理报送为物料变更报送,调整日志记录和响应消息

* feat:添加实验报告简化功能,去除冗余信息并保留关键信息

* feat: 添加任务状态事件发布功能,监控并报告任务运行、超时、完成和错误状态

* fix: 修复添加物料时数据格式错误

* Refactor bioyond_dispensing_station and reaction_station_bioyond YAML configurations

- Removed redundant action value mappings from bioyond_dispensing_station.
- Updated goal properties in bioyond_dispensing_station to use enums for target_stack and other parameters.
- Changed data types for end_point and start_point in reaction_station_bioyond to use string enums (Start, End).
- Simplified descriptions and updated measurement units from μL to mL where applicable.
- Removed unused commands from reaction_station_bioyond to streamline the configuration.

* fix:Change the material unit from μL to mL

* fix:refresh_material_cache

* feat: 动态获取工作流步骤ID,优化工作流配置

* feat: 添加清空服务端所有非核心工作流功能

* fix:修复Bottle类的序列化和反序列化方法

* feat:增强材料缓存更新逻辑,支持处理返回数据中的详细信息

* Add debug log

* feat(workstation): update bioyond config migration and coin cell material search logic

- Migrate bioyond_cell config to JSON structure and remove global variable dependencies
- Implement material search confirmation dialog auto-handling
- Add documentation: 20260113_物料搜寻确认弹窗自动处理功能.md and 20260113_配置迁移修改总结.md

* Refactor module paths for Bioyond devices in YAML configuration files

- Updated the module path for BioyondDispensingStation in bioyond_dispensing_station.yaml to reflect the new directory structure.
- Updated the module path for BioyondReactionStation and BioyondReactor in reaction_station_bioyond.yaml to align with the revised organization of the codebase.

* fix: WareHouse 的不可哈希类型错误,优化父节点去重逻辑

* refactor: Move config from module to instance initialization

* fix: 修正 reaction_station 目录名拼写错误

* feat: Integrate material search logic and cleanup deprecated files

- Update coin_cell_assembly.py with material search dialog handling
- Update YB_warehouses.py with latest warehouse configurations
- Remove outdated documentation and test data files

* Refactor: Use instance attributes for action names and workflow step IDs

* refactor: Split tipbox storage into left and right warehouses

* refactor: Merge tipbox storage left and right into single warehouse

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Andy6M <xieqiming1132@qq.com>
2026-01-17 15:44:18 +08:00
ZiWei
ad21644db0 fix: WareHouse 的不可哈希类型错误,优化父节点去重逻辑 2026-01-14 20:15:05 +08:00
Xuwznln
9dfd58e9af fix parent_uuid fetch when bind_parent_id == node_name 2026-01-14 14:17:29 +08:00
Xuwznln
31c9f9a172 物料更新也是用父节点进行报送 2026-01-13 20:21:37 +08:00
Xuwznln
02cd8de4c5 Add None conversion for tube rack etc. 2026-01-13 17:49:11 +08:00
Xuwznln
a66603ec1c Add set_liquid example. 2026-01-12 22:24:01 +08:00
Xuwznln
ec015e16cd Add create_resource and test_resource example. 2026-01-12 21:17:28 +08:00
Xuwznln
965bf36e8d Add restart.
Temp allow action message.
2026-01-11 21:25:59 +08:00
Xuwznln
aacf3497e0 Add no_update_feedback option. 2026-01-09 17:18:39 +08:00
Xuwznln
657f952e7a Create session_id by edge. 2026-01-09 12:01:57 +08:00
Xuwznln
0165590290 bump version to 0.10.15 2026-01-08 15:37:49 +08:00
Xuwznln
daea1ab54d temp cancel update req 2026-01-08 15:26:31 +08:00
Xuwznln
93cb307396 Fix update with different spot and same parent 2026-01-08 03:46:00 +08:00
Xuwznln
1c312772ae Force update resource when adding new resource / transfer to another resource 2026-01-08 03:07:12 +08:00
Xuwznln
bad1db5094 location not passed to ItemizedCarrier when assign child resource 2026-01-08 03:07:11 +08:00
Xuwznln
f26eb69eca Fix size not pass through. 2026-01-08 03:07:11 +08:00
Xuwznln
12c0770c92 Fix build on macos-intel 2026-01-07 21:11:10 +08:00
Xuwznln
3d2d428a96 Update README.md
Modify resource_tracker file module path.

(cherry picked from commit 8066c200b9)
2026-01-07 20:54:43 +08:00
Xuwznln
78bf57f590 Bump version to 0.10.4 2026-01-07 20:41:23 +08:00
Xuwznln
e227cddab3 Update LICENSE 2026-01-07 20:40:02 +08:00
Xuwznln
f2b993643f Fix drag materials. 2026-01-07 19:40:29 +08:00
Xuwznln
2e14bf197c Fix and tested new create_resource. 2026-01-07 19:26:42 +08:00
Xuwznln
66c18c080a Update create_resource to resource tree mode. 2026-01-07 02:03:43 +08:00
Xuwznln
a1c34f138e Close #208. Fix mock devices.
(cherry picked from commit 28f93737ac)
2025-12-28 23:24:44 +08:00
Xianwei Qi
75bb5ec553 test_transfer_liquid_2 2025-12-26 16:42:50 +08:00
Xianwei Qi
bb95c89829 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-12-26 16:25:19 +08:00
Xianwei Qi
394c140830 test_transfer_liquid 2025-12-26 16:24:55 +08:00
Xuwznln
e6d8d41183 bump version to 0.10.3 2025-12-26 03:26:50 +08:00
Xuwznln
847a300af3 update registry 2025-12-26 03:26:46 +08:00
Xuwznln
a201d7c307 update registry 2025-12-26 03:26:45 +08:00
Xuwznln
3433766bc5 do not modify globally 2025-12-26 03:26:44 +08:00
Xuwznln
7e9e93b29c Prcix9320 (#207)
* 0.10.7 Update (#101)

* Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>

* fix: workstation handlers and vessel_id parsing

* fix: working dir error when input config path
feat: report publish topic when error

* modify default discovery_interval to 15s

* feat: add trace log level

* feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79)

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

* feat: 优化protocol node节点运行日志

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

* refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

* update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84)

* Add:msgs.action

* update: 将版本号从 0.10.3 更新为 0.10.4

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

* fix: station自己的方法注册错误

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

* 修复工站的tracker实例追踪失效问题

* 增加handle检测,增加material edge关系上传

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

* 主机节点信息等支持自动刷新

* 注册表编辑器

* 修复status密集发送时,消息出错

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

- Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes.
- Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume.
- Both actions include response fields for return information and success status.

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

* Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

* 对于PRCXI9320的transfer_group,一对多和多对多

* 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

* Fix: run-column with correct vessel id (#86)

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

* 重新规定了版位推荐的入参

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

* 修复移液站错误的aspirate注册表

* Feature/xprbalance-zhida (#80)

* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* frontend_docs

* create/update resources with POST/PUT for big amount/ small amount data

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

* Workstation templates: Resources and its CRUD, and workstation tasks (#95)

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

* Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities

- Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability.
- Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input.
- Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input.
- Refined `create_diamine_solution_task` to include additional parameters for better task configuration.
- Enhanced schema descriptions and default values for improved user guidance.

* 修复to_plr_resources

* add update remove

* 支持选择器注册表自动生成
支持转运物料

* 修复资源添加

* 修复transfer_resource_to_another生成

* 更新transfer_resource_to_another参数,支持spot入参

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

* 修复同步任务报错不显示的bug

* feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了

* update todo

* modify bioyond/plr converter, bioyond resource registry, and tests

* pass the tests

* update todo

* add conda-pack-build.yml

* add auto install script for conda-pack-build.yml

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* Add version in __init__.py
Update conda-pack-build.yml
Add create_zip_archive.py

* Update conda-pack-build.yml

* Update conda-pack-build.yml (with mamba)

* Update conda-pack-build.yml

* Fix FileNotFoundError

* Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined>

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

* Update registry. Update uuid loop figure method. Update install docs.

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included.

* feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档

refactor: 重新组织LaiYu_Liquid模块目录结构
docs: 添加SOPA移液器和步进电机控制指令文档
fix: 修正设备配置中的最大体积默认值
test: 新增工作台配置测试用例
chore: 删除过时的测试脚本和配置文件

* add

* 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用

- 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py
- 更新所有相关文件中的导入引用
- 保持代码功能不变,仅改善命名一致性
- 测试确认所有导入正常工作

* 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出

- 添加 LaiYuLiquidBackend 到导入列表
- 添加 LaiYuLiquidBackend 到 __all__ 导出列表
- 确保所有主要类都可以正确导入

* 修复大小写文件夹名字

* 电池装配工站二次开发教程(带目录)上传至dev (#94)

* 电池装配工站二次开发教程

* Update intro.md

* 物料教程

* 更新物料教程,json格式注释

* Update prcxi driver & fix transfer_liquid mix_times (#90)

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>

* fix startup env check.
add auto install during one-key installation

* Try fix one-key build on linux

* Complete all one key installation

* fix: rename schema field to resource_schema with serialization and validation aliases (#104)

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>

* Fix one-key installation build

Install conda-pack before pack command

Add conda-pack to base when building one-key installer

Fix param error when using mamba run

Try fix one-key build on linux

* Fix conda pack on windows

* add plr_to_bioyond, and refactor bioyond stations

* modify default config

* Fix one-key installation build for windows

* Fix workstation startup
Update registry

* Fix/resource UUID and doc fix (#109)

* Fix ResourceTreeSet load error

* Raise error when using unsupported type to create ResourceTreeSet

* Fix children key error

* Fix children key error

* Fix workstation resource not tracking

* Fix workstation deck & children resource dupe

* Fix workstation deck & children resource dupe

* Fix multiple resource error

* Fix resource tree update

* Fix resource tree update

* Force confirm uuid

* Tip more error log

* Refactor Bioyond workstation and experiment workflow (#105)

Refactored the Bioyond workstation classes to improve parameter handling and workflow management. Updated experiment.py to use BioyondReactionStation with deck and material mappings, and enhanced workflow step parameter mapping and execution logic. Adjusted JSON experiment configs, improved workflow sequence handling, and added UUID assignment to PLR materials. Removed unused station_config and material cache logic, and added detailed docstrings and debug output for workflow methods.

* Fix resource get.
Fix resource parent not found.
Mapping uuid for all resources.

* mount parent uuid

* Add logging configuration based on BasicConfig in main function

* fix workstation node error

* fix workstation node error

* Update boot example

* temp fix for resource get

* temp fix for resource get

* provide error info when cant find plr type

* pack repo info

* fix to plr type error

* fix to plr type error

* Update regular container method

* support no size init

* fix comprehensive_station.json

* fix comprehensive_station.json

* fix type conversion

* fix state loading for regular container

* Update deploy-docs.yml

* Update deploy-docs.yml

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>

* Close #107
Update doc url.

* Fix/update resource (#112)

* cancel upload_registry

* Refactor Bioyond workstation and experiment workflow -fix (#111)

* refactor(bioyond_studio): 优化材料缓存加载和参数验证逻辑

改进材料缓存加载逻辑以支持多种材料类型和详细材料处理
更新工作流参数验证中的字段名从key/value改为Key/DisplayValue
移除未使用的merge_workflow_with_parameters方法
添加get_station_info方法获取工作站基础信息
清理实验文件中的注释代码和更新导入路径

* fix: 修复资源移除时的父资源检查问题

在BaseROS2DeviceNode中,移除资源前添加对父资源是否为None的检查,避免空指针异常
同时更新Bottle和BottleCarrier类以支持**kwargs参数
修正测试文件中Liquid_feeding_beaker的大小写拼写错误

* correct return message

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>

* fix resource_get in action

* fix(reaction_station): 清空工作流序列和参数避免重复执行 (#113)

在创建任务后清空工作流序列和参数,防止下次执行时累积重复

* Update create_resource device_id

* Update ResourceTracker

add more enumeration in POSE

fix converter in resource_tracker

* Update graphio together with workstation design.

fix(reaction_station): 为步骤参数添加Value字段传个BY后端

fix(bioyond/warehouses): 修正仓库尺寸和物品排列参数

调整仓库的x轴和z轴物品数量以及物品尺寸参数,使其符合4x1x4的规格要求

fix warehouse serialize/deserialize

fix bioyond converter

fix itemized_carrier.unassign_child_resource

allow not-loaded MSG in registry

add layout serializer & converter

warehouseuse A1-D4; add warehouse layout

fix(graphio): 修正bioyond到plr资源转换中的坐标计算错误

Fix resource assignment and type mapping issues

Corrects resource assignment in ItemizedCarrier by using the correct spot key from _ordering. Updates graphio to use 'typeName' instead of 'name' for type mapping in resource_bioyond_to_plr. Renames DummyWorkstation to BioyondWorkstation in workstation_http_service for clarity.

* Update workstation & bioyond example

Refine descriptions in Bioyond reaction station YAML

Updated and clarified field and operation descriptions in the reaction_station_bioyond.yaml file for improved accuracy and consistency. Changes include more precise terminology, clearer parameter explanations, and standardized formatting for operation schemas.

refactor(workstation): 更新反应站参数描述并添加分液站配置文件

修正反应站方法参数描述,使其更准确清晰
添加bioyond_dispensing_station.yaml配置文件

add create_workflow script and test

add invisible_slots to carriers

fix(warehouses): 修正bioyond_warehouse_1x4x4仓库的尺寸参数

调整仓库的num_items_x和num_items_z值以匹配实际布局,并更新物品尺寸参数

save resource get data. allow empty value for layout and cross_section_type

More decks&plates support for bioyond (#115)

refactor(registry): 重构反应站设备配置,简化并更新操作命令

移除旧的自动操作命令,新增针对具体化学操作的命令配置
更新模块路径和配置结构,优化参数定义和描述

fix(dispensing_station): 修正物料信息查询方法调用

将直接调用material_id_query改为通过hardware_interface调用,以符合接口设计规范

* PRCXI Update

修改prcxi连线

prcxi样例图

Create example_prcxi.json

* Update resource extra & uuid.

use ordering to convert identifier to idx

convert identifier to site idx

correct extra key

update extra before transfer

fix multiple instance error

add resource_tree_transfer func

fox itemrized carrier assign child resource

support internal device material transfer

remove extra key

use same callback group

support material extra

support material extra
support update_resource_site in extra

* Update workstation.

modify workstation_architecture docs

bioyond_HR (#133)

* feat: Enhance Bioyond synchronization and resource management

- Implemented synchronization for all material types (consumables, samples, reagents) from Bioyond, logging detailed information for each type.
- Improved error handling and logging during synchronization processes.
- Added functionality to save Bioyond material IDs in UniLab resources for future updates.
- Enhanced the `sync_to_external` method to handle material movements correctly, including querying and creating materials in Bioyond.
- Updated warehouse configurations to support new storage types and improved layout for better resource management.
- Introduced new resource types such as reactors and tip boxes, with detailed specifications.
- Modified warehouse factory to support column offsets for naming conventions (e.g., A05-D08).
- Improved resource tracking by merging extra attributes instead of overwriting them.
- Added a new method for updating resources in Bioyond, ensuring better synchronization of resource changes.

* feat: 添加TipBox和Reactor的配置到bottles.yaml

* fix: 修复液体投料方法中的volume参数处理逻辑

修复solid_feeding_vials方法中的volume参数处理逻辑,优化solvents参数的使用条件

更新液体投料方法,支持通过溶剂信息自动计算体积,添加solvents参数并更新文档描述

Add batch creation methods for vial and solution tasks

添加批量创建90%10%小瓶投料任务和二胺溶液配置任务的功能,更新相关参数和默认值

* 封膜仪、撕膜仪、耗材站接口

* 添加Raman和xrd相关代码

* Resource update & asyncio fix

correct bioyond config

prcxi example

fix append_resource

fix regularcontainer

fix cancel error

fix resource_get param

fix json dumps

support name change during materials change

enable slave mode

change uuid logger to trace level

correct remove_resource stats

disable slave connect websocket

adjust with_children param

modify devices to use correct executor (sleep, create_task)

support sleep and create_task in node

fix run async execution error

* bump version to 0.10.9

update registry

* PRCXI Reset Error Correction (#166)

* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Rewrite setup logic to clear error code

* 初始化 step_mode 属性

* 1114物料手册定义教程byxinyu (#165)

* 宜宾奔耀工站deck前端by_Xinyu

* 构建物料教程byxinyu

* 1114物料手册定义教程

* 3d sim (#97)

* 修改lh的json启动

* 修改lh的json启动

* 修改backend,做成sim的通用backend

* 修改yaml的地址,3D模型适配网页生产环境

* 添加laiyu硬件连接

* 修改移液枪的状态判断方法,

修改移液枪的状态判断方法,
添加三轴的表定点与零点之间的转换
添加三轴真实移动的backend

* 修改laiyu移液站

简化移动方法,
取消软件限制位置,
修改当值使用Z轴时也需要重新复位Z轴的问题

* 更新lh以及laiyu workshop

1,现在可以直接通过修改backend,适配其他的移液站,主类依旧使用LiquidHandler,不用重新编写

2,修改枪头判断标准,使用枪头自身判断而不是类的判断,

3,将归零参数用毫米计算,方便手动调整,

4,修改归零方式,上电使用机械归零,确定机械零点,手动归零设置工作区域零点方便计算,二者互不干涉

* 修改枪头动作

* 修改虚拟仿真方法

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: Junhan Chang <changjh@dp.tech>

* 标准化opcua设备接入unilab (#78)

* 初始提交,只保留工作区当前状态

* remove redundant arm_slider meshes

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>

* add new laiyu liquid driver, yaml and json files (#164)

* HR物料同步,前端展示位置修复 (#135)

* 更新Bioyond工作站配置,添加新的物料类型映射和载架定义,优化物料查询逻辑

* 添加Bioyond实验配置文件,定义物料类型映射和设备配置

* 更新bioyond_warehouse_reagent_stack方法,修正试剂堆栈尺寸和布局描述

* 更新Bioyond实验配置,修正物料类型映射,优化设备配置

* 更新Bioyond资源同步逻辑,优化物料入库流程,增强错误处理和日志记录

* 更新Bioyond资源,添加配液站和反应站专用载架,优化仓库工厂函数的排序方式

* 更新Bioyond资源,添加配液站和反应站相关载架,优化试剂瓶和样品瓶配置

* 更新Bioyond实验配置,修正试剂瓶载架ID,确保与设备匹配

* 更新Bioyond资源,移除反应站单烧杯载架,添加反应站单烧瓶载架分类

* Refactor Bioyond resource synchronization and update bottle carrier definitions

- Removed traceback printing in error handling for Bioyond synchronization.
- Enhanced logging for existing Bioyond material ID usage during synchronization.
- Added new bottle carrier definitions for single flask and updated existing ones.
- Refactored dispensing station and reaction station bottle definitions for clarity and consistency.
- Improved resource mapping and error handling in graphio for Bioyond resource conversion.
- Introduced layout parameter in warehouse factory for better warehouse configuration.

* 更新Bioyond仓库工厂,添加排序方式支持,优化坐标计算逻辑

* 更新Bioyond载架和甲板配置,调整样品板尺寸和仓库坐标

* 更新Bioyond资源同步,增强占用位置日志信息,修正坐标转换逻辑

* 更新Bioyond反应站和分配站配置,调整材料类型映射和ID,移除不必要的项

* support name change during materials change

* fix json dumps

* correct tip

* 优化调度器API路径,更新相关方法描述

* 更新 BIOYOND 载架相关文档,调整 API 以支持自带试剂瓶的载架类型,修复资源获取时的子物料处理逻辑

* 实现资源删除时的同步处理,优化出库操作逻辑

* 修复 ItemizedCarrier 中的可见性逻辑

* 保存 Bioyond 原始信息到 unilabos_extra,以便出库时查询

* 根据 resource.capacity 判断是试剂瓶(载架)还是多瓶载架,走不同的奔曜转换

* Fix bioyond bottle_carriers ordering

* 优化 Bioyond 物料同步逻辑,增强坐标解析和位置更新处理

* disable slave connect websocket

* correct remove_resource stats

* change uuid logger to trace level

* enable slave mode

* refactor(bioyond): 统一资源命名并优化物料同步逻辑

- 将DispensingStation和ReactionStation资源统一为PolymerStation命名
- 优化物料同步逻辑,支持耗材类型(typeMode=0)的查询
- 添加物料默认参数配置功能
- 调整仓库坐标布局
- 清理废弃资源定义

* feat(warehouses): 为仓库函数添加col_offset和layout参数

* refactor: 更新实验配置中的物料类型映射命名

将DispensingStation和ReactionStation的物料类型映射统一更名为PolymerStation,保持命名一致性

* fix: 更新实验配置中的载体名称从6VialCarrier到6StockCarrier

* feat(bioyond): 实现物料创建与入库分离逻辑

将物料同步流程拆分为两个独立阶段:transfer阶段只创建物料,add阶段执行入库
简化状态检查接口,仅返回连接状态

* fix(reaction_station): 修正液体进料烧杯体积单位并增强返回结果

将液体进料烧杯的体积单位从μL改为g以匹配实际使用场景
在返回结果中添加merged_workflow和order_params字段,提供更完整的工作流信息

* feat(dispensing_station): 在任务创建返回结果中添加order_params信息

在create_order方法返回结果中增加order_params字段,以便调用方获取完整的任务参数

* fix(dispensing_station): 修改90%物料分配逻辑从分成3份改为直接使用

原逻辑将主称固体平均分成3份作为90%物料,现改为直接使用main_portion

* feat(bioyond): 添加任务编码和任务ID的输出,支持批量任务创建后的状态监控

* refactor(registry): 简化设备配置中的任务结果处理逻辑

将多个单独的任务编码和ID字段合并为统一的return_info字段
更新相关描述以反映新的数据结构

* feat(工作站): 添加HTTP报送服务和任务完成状态跟踪

- 在graphio.py中添加API必需字段
- 实现工作站HTTP服务启动和停止逻辑
- 添加任务完成状态跟踪字典和等待方法
- 重写任务完成报送处理方法记录状态
- 支持批量任务完成等待和报告获取

* refactor(dispensing_station): 移除wait_for_order_completion_and_get_report功能

该功能已被wait_for_multiple_orders_and_get_reports替代,简化代码结构

* fix: 更新任务报告API错误

* fix(workstation_http_service): 修复状态查询中device_id获取逻辑

处理状态查询时安全获取device_id,避免因属性不存在导致的异常

* fix(bioyond_studio): 改进物料入库失败时的错误处理和日志记录

在物料入库API调用失败时,添加更详细的错误信息打印
同时修正station.py中对空响应和失败情况的判断逻辑

* refactor(bioyond): 优化瓶架载体的分配逻辑和注释说明

重构瓶架载体的分配逻辑,使用嵌套循环替代硬编码索引分配
添加更详细的坐标映射说明,明确PLR与Bioyond坐标的对应关系

* fix(bioyond_rpc): 修复物料入库成功时无data字段返回空的问题

当API返回成功但无data字段时,返回包含success标识的字典而非空字典

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
Co-authored-by: Junhan Chang <changjh@dp.tech>

* nmr

* Update devices

* bump version to 0.10.10

* Update repo files.

* Add get_resource_with_dir & get_resource method

* fix camera & workstation & warehouse & reaction station driver

* update docs, test examples
fix liquid_handler init bug

* bump version to 0.10.11

* Add startup_json_path, disable_browser, port config

* Update oss config

* feat(bioyond_studio): 添加项目API接口支持及优化物料管理功能

添加通用项目API接口方法(_post_project_api, _delete_project_api)用于与LIMS系统交互
实现compute_experiment_design方法用于实验设计计算
新增brief_step_parameters等订单相关接口方法
优化物料转移逻辑,增加异步任务处理
扩展BioyondV1RPC类,添加批量物料操作、订单状态管理等功能

* feat(bioyond): 添加测量小瓶仓库和更新仓库工厂函数参数

* Support unilabos_samples key

* add session_id and normal_exit

* Add result schema and add TypedDict conversion.

* Fix port error

* Add backend api and update doc

* Add get_regular_container func

* Add get_regular_container func

* Transfer_liquid (#176)

* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Typo in base_device_node.py

* Enhance liquid handling functionality by adding support for multiple transfer modes (one-to-many, one-to-one, many-to-one) and improving parameter validation. Default channel usage is set when not specified. Adjusted mixing logic to ensure it only occurs when valid conditions are met. Updated documentation for clarity.

* Auto dump logs, fix workstation input schema

* Fix startup with remote resource error

Resource dict fully change to "pose" key

Update oss link

Reduce pylabrobot conversion warning & force enable log dump.

更新 logo 图片

* signal when host node is ready

* fix ros2 future

print all logs to file
fix resource dict dump error

* update version to 0.10.12

* 修改sample_uuid的返回值

* 修改pose标签设定机制

* 添加 aspiate函数返回值

* 返回dispense后的sample_uuid

* 添加self.pending_liquids_dict的重置方法

* 修改prcxi的json文件,解决trach错误问题

* 修改prcxijson,防止PlateT4的硬件错误

* 对laiyu移液站进行部分修改,取消多次初始化的问题

* 修改根据新的物料格式,修改可视化

* 添加切换枪头方法,添加mock振荡与加热方法

* 夹爪添加

* 删除多余的laiyu部分

* 云端可启动夹爪

* Delete __init__.py

* Enhance PRCXI9300 classes with new Container and TipRack implementations, improving state management and initialization logic. Update JSON configuration to reflect type changes for containers and plates.

* 修改上传数据

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
Co-authored-by: hh.(SII) <103566763+Mile-Away@users.noreply.github.com>
Co-authored-by: Xianwei Qi <qxw@stu.pku.edu.cn>
Co-authored-by: WenzheG <wenzheguo32@gmail.com>
Co-authored-by: Harry Liu <113173203+ALITTLELZ@users.noreply.github.com>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>
Co-authored-by: tt <166512503+tt11142023@users.noreply.github.com>
Co-authored-by: xyc <49015816+xiaoyu10031@users.noreply.github.com>
Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: zhangshixiang <554662886@qq.com>
Co-authored-by: ALITTLELZ <l_LZlz@163.com>
2025-12-26 02:28:56 +08:00
Xuwznln
9e1e6da505 Add topic config 2025-12-26 02:26:17 +08:00
xyc
8a0f000bab add camera driver (#191)
* add camera driver

* add init.py file to cameraSII driver
2025-12-23 18:41:43 +08:00
Xie Qiming
2ffeb49acb 增强新威电池测试系统 OSS 上传功能 / Enhanced Neware Battery Test System OSS Upload (#196)
* feat: neware-oss-upload-enhancement

* feat(neware): enhance OSS upload with metadata and workflow handles
2025-12-23 18:41:15 +08:00
Roy
5fec753fb9 Add post process station and related resources (#195)
* Add post process station and related resources

- Created JSON configuration for post_process_station and its child post_process_deck.
- Added YAML definitions for post_process_station, bottle carriers, bottles, and deck resources.
- Implemented Python classes for bottle carriers, bottles, decks, and warehouses to manage resources in the post process.
- Established a factory method for creating warehouses with customizable dimensions and layouts.
- Defined the structure and behavior of the post_process_deck and its associated warehouses.

* feat(post_process): add post_process_station and related warehouse functionality

- Introduced post_process_station.json to define the post-processing station structure.
- Implemented post_process_warehouse.py to create warehouse configurations with customizable layouts.
- Added warehouses.py for specific warehouse configurations (4x3x1).
- Updated post_process_station.yaml to reflect new module paths for OpcUaClient.
- Refactored bottle carriers and bottles YAML files to point to the new module paths.
- Adjusted deck.yaml to align with the new organizational structure for post_process_deck.
2025-12-23 18:40:09 +08:00
shuchang
acbaff7bb7 prcxi resource (#202)
* prcxi resource

* prcxi_resource

* Fix upload error not showing.
Support str type category.

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
2025-12-23 15:08:04 +08:00
Xuwznln
706323dc3e Merge remote-tracking branch 'origin/dev' into dev 2025-12-23 14:50:54 +08:00
Xuwznln
b0804d939c Fix upload error not showing.
Support str type category.
2025-12-23 14:50:35 +08:00
ZiWei
97788b4e07 feat: introduce wait_time command and configurable device communication timeout. 2025-12-19 18:02:38 +08:00
ZiWei
39cc280c91 feat: Add SyringePump (SY-03B) driver with unified serial/TCP transport for chinwe device, including registry and test configurations. 2025-12-19 03:05:11 +08:00
Xuwznln
d0ac452405 Update organic syn station.
(cherry picked from commit 13a6795657)
2025-12-15 02:34:51 +08:00
Xuwznln
152d3a7563 Update docs 2025-12-14 13:12:19 +08:00
Xuwznln
ef14737839 update "laiyu" missing init file. 2025-12-14 13:08:27 +08:00
Xuwznln
5d5569121c fix "laiyu" missing init file. 2025-12-14 12:55:25 +08:00
Xuwznln
d23e85ade4 fix "🐛 fix" 2025-12-14 01:17:24 +08:00
Haohui
02afafd423 🐛 fix: config file is overwrited by default args even if not be set. 2025-12-12 23:55:38 +08:00
Xianwei Qi
6ac510dcd2 mix
修改了mix,仿真流程报错问题
2025-12-11 23:26:11 +08:00
Xuwznln
ed56c1eba2 reduce logs 2025-12-08 19:23:53 +08:00
Xuwznln
16ee3de086 Add workflow upload func. 2025-12-08 19:12:05 +08:00
Junhan Chang
ced961050d add unilabos/workflow and entrypoint 2025-12-07 17:50:27 +08:00
Xuwznln
11b2c99836 update version to 0.10.12
(cherry picked from commit b1cdef9185)
2025-12-04 18:47:44 +08:00
Xuwznln
04024bc8a3 fix ros2 future 2025-12-04 18:44:50 +08:00
Xuwznln
154048107d print all logs to file
fix resource dict dump error
2025-12-04 16:04:56 +08:00
Xuwznln
0b896870ba signal when host node is ready 2025-12-02 12:00:41 +08:00
Xuwznln
ee609e4aa2 Fix startup with remote resource error 2025-12-02 11:49:59 +08:00
Xuwznln
5551fbf360 Resource dict fully change to "pose" key 2025-12-02 03:45:16 +08:00
Xuwznln
e13b250632 Update oss link 2025-12-01 12:23:07 +08:00
Xuwznln
b8278c5026 Reduce pylabrobot conversion warning & force enable log dump. 2025-11-28 22:41:50 +08:00
ZiWei
53e767a054 更新 logo 图片 2025-11-28 11:35:05 +08:00
Xuwznln
cf7032fa81 Auto dump logs, fix workstation input schema 2025-11-27 14:24:50 +08:00
Xuwznln
97681ba433 Add get_regular_container func 2025-11-27 13:47:47 +08:00
Xuwznln
3fa81ab4f6 Add get_regular_container func
(cherry picked from commit ed8ee29732)
2025-11-27 13:47:46 +08:00
Harry Liu
9f4a69ddf5 Transfer_liquid (#176)
* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Typo in base_device_node.py

* Enhance liquid handling functionality by adding support for multiple transfer modes (one-to-many, one-to-one, many-to-one) and improving parameter validation. Default channel usage is set when not specified. Adjusted mixing logic to ensure it only occurs when valid conditions are met. Updated documentation for clarity.
2025-11-26 19:30:42 +08:00
Xuwznln
05ae4e72df Add backend api and update doc 2025-11-26 19:03:31 +08:00
Xuwznln
2870c04086 Fix port error
(cherry picked from commit f1ad0c9c96)
2025-11-25 15:22:19 +08:00
Xuwznln
343e87df0d Add result schema and add TypedDict conversion.
(cherry picked from commit 8fa3407649)
2025-11-25 15:22:18 +08:00
Xuwznln
5d0807cba6 add session_id and normal_exit 2025-11-20 22:42:42 +08:00
Xuwznln
4875977d5f Support unilabos_samples key
(cherry picked from commit 554bcade24)
2025-11-19 15:55:21 +08:00
Xuwznln
956b1c905b Add startup_json_path, disable_browser, port config
(cherry picked from commit acf5fdebf8)
2025-11-19 14:23:39 +08:00
Xuwznln
944911c52a bump version to 0.10.11
(cherry picked from commit 7f7b1c13c0)
2025-11-19 14:23:36 +08:00
Xuwznln
a13b790926 Revert "feat(main): enhance argument parsing for addr and port with priority handling"
This reverts commit 7cc2fe036f.
2025-11-19 14:22:58 +08:00
Xuwznln
9feadd68c6 Update oss config
(cherry picked from commit d39662f65f)
2025-11-19 14:22:26 +08:00
ZiWei
c68d5246d0 feat(bioyond): 添加测量小瓶仓库和更新仓库工厂函数参数 2025-11-19 11:28:35 +08:00
ZiWei
49073f2c77 feat(bioyond_studio): 添加项目API接口支持及优化物料管理功能
添加通用项目API接口方法(_post_project_api, _delete_project_api)用于与LIMS系统交互
实现compute_experiment_design方法用于实验设计计算
新增brief_step_parameters等订单相关接口方法
优化物料转移逻辑,增加异步任务处理
扩展BioyondV1RPC类,添加批量物料操作、订单状态管理等功能
2025-11-18 18:57:47 +08:00
ZiWei
b2afc29f15 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-11-18 18:57:03 +08:00
Xuwznln
4061280f6b Support internal test examples 2025-11-18 18:43:29 +08:00
Xuwznln
6a681e1d73 Update docs 2025-11-18 18:43:29 +08:00
Xuwznln
653e6e1ac3 liquid_handler default use chatterbox instead of rviz backend 2025-11-18 18:43:28 +08:00
ZiWei
2c774bcd1d feat(反应站): 添加反应器子设备支持
- 在设备注册表中添加反应器子设备配置
- 实现BioyondReactor类用于处理反应器数据
- 更新反应站主设备以支持子设备数据同步
- 在测试配置中添加5个反应器实例
2025-11-18 18:43:28 +08:00
ZiWei
2ba395b681 fix(camera): 修正摄像头配置,更新设备ID和UUID参数 2025-11-18 18:43:28 +08:00
ZiWei
b6b3d59083 feat(反应站): 添加反应器子设备支持
- 在设备注册表中添加反应器子设备配置
- 实现BioyondReactor类用于处理反应器数据
- 更新反应站主设备以支持子设备数据同步
- 在测试配置中添加5个反应器实例
2025-11-17 22:55:51 +08:00
ZiWei
f40e3f521c fix(camera): 修正摄像头配置,更新设备ID和UUID参数 2025-11-17 17:07:07 +08:00
Haohui
7cc2fe036f feat(main): enhance argument parsing for addr and port with priority handling 2025-11-16 22:53:54 +08:00
ZiWei
f81d20bb1d fix(warehouse): 修正BioYond 4x4x1仓库的物品尺寸参数 2025-11-16 15:47:10 +08:00
ZiWei
db1b5a869f feat(workstation): 添加温度/粘度报送处理功能
- 在反应站设备配置中添加温度/粘度相关状态类型
- 实现温度/粘度报送处理逻辑并添加ROS消息发布
- 扩展HTTP服务支持温度/粘度报送端点
- 添加HTTP请求日志记录功能
2025-11-16 14:35:53 +08:00
Xuwznln
0136630700 Fix http_client 2025-11-15 23:33:02 +08:00
Xuwznln
3c31811f9e Add get_resource_with_dir & get_resource method
(cherry picked from commit 4189a2cfbe)
2025-11-15 22:54:44 +08:00
ZiWei
64f02ff129 添加物料转移到反应站的功能,支持多组转移任务的配置与执行 2025-11-15 17:49:59 +08:00
ZiWei
7d097b8222 添加从报告中提取实际加料量的功能,支持液体进料滴定的自动公式计算 2025-11-15 13:30:22 +08:00
Xuwznln
d266d21104 Update repo files.
(cherry picked from commit 48895a9bb1)
2025-11-15 03:16:04 +08:00
Xuwznln
b6d0bbcb17 bump version to 0.10.10 2025-11-15 03:10:31 +08:00
Xuwznln
31ebff8e37 Update devices 2025-11-15 03:10:05 +08:00
WenzheG
2132895ba2 nmr 2025-11-15 03:02:23 +08:00
ZiWei
850eeae55a HR物料同步,前端展示位置修复 (#135)
* 更新Bioyond工作站配置,添加新的物料类型映射和载架定义,优化物料查询逻辑

* 添加Bioyond实验配置文件,定义物料类型映射和设备配置

* 更新bioyond_warehouse_reagent_stack方法,修正试剂堆栈尺寸和布局描述

* 更新Bioyond实验配置,修正物料类型映射,优化设备配置

* 更新Bioyond资源同步逻辑,优化物料入库流程,增强错误处理和日志记录

* 更新Bioyond资源,添加配液站和反应站专用载架,优化仓库工厂函数的排序方式

* 更新Bioyond资源,添加配液站和反应站相关载架,优化试剂瓶和样品瓶配置

* 更新Bioyond实验配置,修正试剂瓶载架ID,确保与设备匹配

* 更新Bioyond资源,移除反应站单烧杯载架,添加反应站单烧瓶载架分类

* Refactor Bioyond resource synchronization and update bottle carrier definitions

- Removed traceback printing in error handling for Bioyond synchronization.
- Enhanced logging for existing Bioyond material ID usage during synchronization.
- Added new bottle carrier definitions for single flask and updated existing ones.
- Refactored dispensing station and reaction station bottle definitions for clarity and consistency.
- Improved resource mapping and error handling in graphio for Bioyond resource conversion.
- Introduced layout parameter in warehouse factory for better warehouse configuration.

* 更新Bioyond仓库工厂,添加排序方式支持,优化坐标计算逻辑

* 更新Bioyond载架和甲板配置,调整样品板尺寸和仓库坐标

* 更新Bioyond资源同步,增强占用位置日志信息,修正坐标转换逻辑

* 更新Bioyond反应站和分配站配置,调整材料类型映射和ID,移除不必要的项

* support name change during materials change

* fix json dumps

* correct tip

* 优化调度器API路径,更新相关方法描述

* 更新 BIOYOND 载架相关文档,调整 API 以支持自带试剂瓶的载架类型,修复资源获取时的子物料处理逻辑

* 实现资源删除时的同步处理,优化出库操作逻辑

* 修复 ItemizedCarrier 中的可见性逻辑

* 保存 Bioyond 原始信息到 unilabos_extra,以便出库时查询

* 根据 resource.capacity 判断是试剂瓶(载架)还是多瓶载架,走不同的奔曜转换

* Fix bioyond bottle_carriers ordering

* 优化 Bioyond 物料同步逻辑,增强坐标解析和位置更新处理

* disable slave connect websocket

* correct remove_resource stats

* change uuid logger to trace level

* enable slave mode

* refactor(bioyond): 统一资源命名并优化物料同步逻辑

- 将DispensingStation和ReactionStation资源统一为PolymerStation命名
- 优化物料同步逻辑,支持耗材类型(typeMode=0)的查询
- 添加物料默认参数配置功能
- 调整仓库坐标布局
- 清理废弃资源定义

* feat(warehouses): 为仓库函数添加col_offset和layout参数

* refactor: 更新实验配置中的物料类型映射命名

将DispensingStation和ReactionStation的物料类型映射统一更名为PolymerStation,保持命名一致性

* fix: 更新实验配置中的载体名称从6VialCarrier到6StockCarrier

* feat(bioyond): 实现物料创建与入库分离逻辑

将物料同步流程拆分为两个独立阶段:transfer阶段只创建物料,add阶段执行入库
简化状态检查接口,仅返回连接状态

* fix(reaction_station): 修正液体进料烧杯体积单位并增强返回结果

将液体进料烧杯的体积单位从μL改为g以匹配实际使用场景
在返回结果中添加merged_workflow和order_params字段,提供更完整的工作流信息

* feat(dispensing_station): 在任务创建返回结果中添加order_params信息

在create_order方法返回结果中增加order_params字段,以便调用方获取完整的任务参数

* fix(dispensing_station): 修改90%物料分配逻辑从分成3份改为直接使用

原逻辑将主称固体平均分成3份作为90%物料,现改为直接使用main_portion

* feat(bioyond): 添加任务编码和任务ID的输出,支持批量任务创建后的状态监控

* refactor(registry): 简化设备配置中的任务结果处理逻辑

将多个单独的任务编码和ID字段合并为统一的return_info字段
更新相关描述以反映新的数据结构

* feat(工作站): 添加HTTP报送服务和任务完成状态跟踪

- 在graphio.py中添加API必需字段
- 实现工作站HTTP服务启动和停止逻辑
- 添加任务完成状态跟踪字典和等待方法
- 重写任务完成报送处理方法记录状态
- 支持批量任务完成等待和报告获取

* refactor(dispensing_station): 移除wait_for_order_completion_and_get_report功能

该功能已被wait_for_multiple_orders_and_get_reports替代,简化代码结构

* fix: 更新任务报告API错误

* fix(workstation_http_service): 修复状态查询中device_id获取逻辑

处理状态查询时安全获取device_id,避免因属性不存在导致的异常

* fix(bioyond_studio): 改进物料入库失败时的错误处理和日志记录

在物料入库API调用失败时,添加更详细的错误信息打印
同时修正station.py中对空响应和失败情况的判断逻辑

* refactor(bioyond): 优化瓶架载体的分配逻辑和注释说明

重构瓶架载体的分配逻辑,使用嵌套循环替代硬编码索引分配
添加更详细的坐标映射说明,明确PLR与Bioyond坐标的对应关系

* fix(bioyond_rpc): 修复物料入库成功时无data字段返回空的问题

当API返回成功但无data字段时,返回包含success标识的字典而非空字典

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
Co-authored-by: Junhan Chang <changjh@dp.tech>
2025-11-15 02:57:48 +08:00
xyc
d869c14233 add new laiyu liquid driver, yaml and json files (#164) 2025-11-15 02:52:19 +08:00
Harry Liu
24101b3cec change 9320 desk row number to 4 (#106)
* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo
2025-11-15 02:52:08 +08:00
tt
3bf8aad4d5 标准化opcua设备接入unilab (#78)
* 初始提交,只保留工作区当前状态

* remove redundant arm_slider meshes

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
2025-11-15 02:50:52 +08:00
q434343
a599eb70e5 3d sim (#97)
* 修改lh的json启动

* 修改lh的json启动

* 修改backend,做成sim的通用backend

* 修改yaml的地址,3D模型适配网页生产环境

* 添加laiyu硬件连接

* 修改移液枪的状态判断方法,

修改移液枪的状态判断方法,
添加三轴的表定点与零点之间的转换
添加三轴真实移动的backend

* 修改laiyu移液站

简化移动方法,
取消软件限制位置,
修改当值使用Z轴时也需要重新复位Z轴的问题

* 更新lh以及laiyu workshop

1,现在可以直接通过修改backend,适配其他的移液站,主类依旧使用LiquidHandler,不用重新编写

2,修改枪头判断标准,使用枪头自身判断而不是类的判断,

3,将归零参数用毫米计算,方便手动调整,

4,修改归零方式,上电使用机械归零,确定机械零点,手动归零设置工作区域零点方便计算,二者互不干涉

* 修改枪头动作

* 修改虚拟仿真方法

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: Junhan Chang <changjh@dp.tech>
2025-11-15 02:50:17 +08:00
lixinyu1011
0bf6994f95 1114物料手册定义教程byxinyu (#165)
* 宜宾奔耀工站deck前端by_Xinyu

* 构建物料教程byxinyu

* 1114物料手册定义教程
2025-11-15 02:49:17 +08:00
Harry Liu
c36f53791c PRCXI Reset Error Correction (#166)
* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Rewrite setup logic to clear error code

* 初始化 step_mode 属性
2025-11-15 02:48:46 +08:00
Xuwznln
eb4d2d96c5 bump version to 0.10.9 2025-11-15 02:28:59 +08:00
Xuwznln
8233c41b1d update registry 2025-11-15 02:27:44 +08:00
Xuwznln
0dfd4ce8a8 correct bioyond config 2025-11-15 02:27:32 +08:00
WenzheG
7953b3820e 添加Raman和xrd相关代码 2025-11-15 02:23:09 +08:00
Guangxin Zhang
eed233fa76 封膜仪、撕膜仪、耗材站接口 2025-11-15 02:14:45 +08:00
Xuwznln
0c55147ee4 prcxi example 2025-11-13 17:30:21 +08:00
Xuwznln
ce6267b8e0 fix append_resource 2025-11-13 15:45:41 +08:00
Xuwznln
975e51cd96 fix regularcontainer 2025-11-11 04:44:33 +08:00
Xuwznln
c5056b381c fix cancel error 2025-11-11 04:13:53 +08:00
Xuwznln
c35da65b15 fix resource_get param 2025-11-08 14:40:45 +08:00
Xuwznln
659cf05be6 fix json dumps 2025-11-08 12:08:46 +08:00
Xuwznln
3b8deb4d1d support name change during materials change 2025-11-08 12:08:45 +08:00
Xuwznln
c796615f9f enable slave mode 2025-11-07 21:15:05 +08:00
Xuwznln
a5bad6074f change uuid logger to trace level 2025-11-07 21:15:05 +08:00
Xuwznln
1d3a07a736 correct remove_resource stats 2025-11-07 21:15:03 +08:00
Xuwznln
cc2cd57cdf disable slave connect websocket 2025-11-07 20:39:26 +08:00
Xuwznln
39bb7dc627 adjust with_children param 2025-11-03 16:31:37 +08:00
Xuwznln
0fda155f55 modify devices to use correct executor (sleep, create_task) 2025-11-03 15:49:11 +08:00
Xuwznln
6e3eacd2f0 support sleep and create_task in node 2025-11-03 15:42:12 +08:00
Xuwznln
062f1a2153 fix run async execution error 2025-10-31 21:43:25 +08:00
Junhan Chang
61e8d67800 modify workstation_architecture docs 2025-10-30 17:29:47 +08:00
ZiWei
d0884cdbd8 bioyond_HR (#133)
* feat: Enhance Bioyond synchronization and resource management

- Implemented synchronization for all material types (consumables, samples, reagents) from Bioyond, logging detailed information for each type.
- Improved error handling and logging during synchronization processes.
- Added functionality to save Bioyond material IDs in UniLab resources for future updates.
- Enhanced the `sync_to_external` method to handle material movements correctly, including querying and creating materials in Bioyond.
- Updated warehouse configurations to support new storage types and improved layout for better resource management.
- Introduced new resource types such as reactors and tip boxes, with detailed specifications.
- Modified warehouse factory to support column offsets for naming conventions (e.g., A05-D08).
- Improved resource tracking by merging extra attributes instead of overwriting them.
- Added a new method for updating resources in Bioyond, ensuring better synchronization of resource changes.

* feat: 添加TipBox和Reactor的配置到bottles.yaml

* fix: 修复液体投料方法中的volume参数处理逻辑
2025-10-29 12:10:05 +08:00
ZiWei
545ea45024 修复solid_feeding_vials方法中的volume参数处理逻辑,优化solvents参数的使用条件 2025-10-29 11:24:37 +08:00
ZiWei
b9ddee8f2c 更新液体投料方法,支持通过溶剂信息自动计算体积,添加solvents参数并更新文档描述 2025-10-28 00:12:33 +08:00
ZiWei
a0c5095304 Add batch creation methods for vial and solution tasks
添加批量创建90%10%小瓶投料任务和二胺溶液配置任务的功能,更新相关参数和默认值
2025-10-27 23:55:32 +08:00
Xuwznln
e504505137 use ordering to convert identifier to idx 2025-10-24 02:58:50 +08:00
Xuwznln
4d9d5701e9 use ordering to convert identifier to idx 2025-10-24 02:56:07 +08:00
Xuwznln
6016c4b588 convert identifier to site idx 2025-10-24 02:51:45 +08:00
Xuwznln
be02bef9c4 correct extra key 2025-10-24 02:42:36 +08:00
Xuwznln
e62f0c2585 correct extra key 2025-10-24 02:39:28 +08:00
Xuwznln
b6de0623e2 update extra before transfer 2025-10-24 02:36:47 +08:00
Xuwznln
9d081e9fcd fix multiple instance error 2025-10-24 02:32:33 +08:00
Xuwznln
85a58e3464 fix multiple instance error 2025-10-24 02:29:46 +08:00
Xuwznln
85590672d8 fix multiple instance error 2025-10-24 02:24:44 +08:00
Xuwznln
1d4018196d add resource_tree_transfer func 2025-10-24 02:18:12 +08:00
Xuwznln
5d34f742af fox itemrized carrier assign child resource 2025-10-24 02:09:02 +08:00
Xuwznln
5bef19e6d6 support internal device material transfer 2025-10-23 17:32:09 +08:00
Xuwznln
f816799753 remove extra key 2025-10-23 12:01:12 +08:00
Xuwznln
a45d841769 remove extra key 2025-10-23 11:37:26 +08:00
Xuwznln
7f0b33b3e3 use same callback group 2025-10-23 01:52:33 +08:00
Xuwznln
2006406a24 support material extra 2025-10-22 14:51:07 +08:00
Xuwznln
f94985632b support material extra
support update_resource_site in extra
2025-10-22 14:50:05 +08:00
Xianwei Qi
12ba110569 修改prcxi连线 2025-10-22 14:00:38 +08:00
ZiWei
97212be8b7 Refine descriptions in Bioyond reaction station YAML
Updated and clarified field and operation descriptions in the reaction_station_bioyond.yaml file for improved accuracy and consistency. Changes include more precise terminology, clearer parameter explanations, and standardized formatting for operation schemas.
2025-10-21 23:32:41 +08:00
ZiWei
9bdd42f12f refactor(workstation): 更新反应站参数描述并添加分液站配置文件
修正反应站方法参数描述,使其更准确清晰
添加bioyond_dispensing_station.yaml配置文件
2025-10-21 23:30:45 +08:00
Xianwei Qi
627140da03 prcxi样例图 2025-10-21 21:15:54 +08:00
Xianwei Qi
5ceedb0565 Create example_prcxi.json 2025-10-21 20:38:40 +08:00
Junhan Chang
8c77a20c43 add create_workflow script and test 2025-10-21 20:32:00 +08:00
Xuwznln
3ff894feee add invisible_slots to carriers 2025-10-21 17:51:30 +08:00
ZiWei
fa5896ffdb fix(warehouses): 修正bioyond_warehouse_1x4x4仓库的尺寸参数
调整仓库的num_items_x和num_items_z值以匹配实际布局,并更新物品尺寸参数
2025-10-21 17:15:51 +08:00
Xuwznln
eb504803ac save resource get data. allow empty value for layout and cross_section_type 2025-10-21 16:55:43 +08:00
lixinyu1011
8b0c845661 More decks&plates support for bioyond (#115) 2025-10-21 16:25:54 +08:00
Xuwznln
693873bfa9 save resource get data. allow empty value for layout and cross_section_type 2025-10-21 16:22:52 +08:00
ZiWei
57da2d8da2 refactor(registry): 重构反应站设备配置,简化并更新操作命令
移除旧的自动操作命令,新增针对具体化学操作的命令配置
更新模块路径和配置结构,优化参数定义和描述
2025-10-21 14:52:27 +08:00
ZiWei
8d1fd01259 fix(dispensing_station): 修正物料信息查询方法调用
将直接调用material_id_query改为通过hardware_interface调用,以符合接口设计规范
2025-10-21 13:49:36 +08:00
Xuwznln
388259e64b Update create_resource device_id
(cherry picked from commit bc30f23e34)
2025-10-20 21:47:46 +08:00
ZiWei
2c130e7f37 fix(reaction_station): 为步骤参数添加Value字段传个BY后端 2025-10-20 10:40:51 +08:00
ZiWei
9f7c3f02f9 fix(bioyond/warehouses): 修正仓库尺寸和物品排列参数
调整仓库的x轴和z轴物品数量以及物品尺寸参数,使其符合4x1x4的规格要求
2025-10-19 08:36:40 +08:00
Junhan Chang
19dd80dcdb fix warehouse serialize/deserialize 2025-10-19 08:18:18 +08:00
Junhan Chang
9d5ed627a2 fix bioyond converter 2025-10-19 05:21:41 +08:00
Junhan Chang
2d0ff87bc8 fix itemized_carrier.unassign_child_resource 2025-10-19 05:19:19 +08:00
Junhan Chang
d78475de9a allow not-loaded MSG in registry 2025-10-19 05:18:15 +08:00
Junhan Chang
88ae56806c add layout serializer & converter 2025-10-18 20:53:03 +08:00
Junhan Chang
95dd8beb81 warehouseuse A1-D4; add warehouse layout 2025-10-18 20:27:50 +08:00
ZiWei
4ab3fadbec fix(graphio): 修正bioyond到plr资源转换中的坐标计算错误 2025-10-18 19:25:23 +08:00
ZiWei
229888f834 Fix resource assignment and type mapping issues
Corrects resource assignment in ItemizedCarrier by using the correct spot key from _ordering. Updates graphio to use 'typeName' instead of 'name' for type mapping in resource_bioyond_to_plr. Renames DummyWorkstation to BioyondWorkstation in workstation_http_service for clarity.
2025-10-18 18:55:16 +08:00
ZiWei
b443b39ebf Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-10-18 16:49:22 +08:00
Junhan Chang
0434bbc15b add more enumeration in POSE 2025-10-18 16:46:34 +08:00
ZiWei
5791b81954 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-10-18 16:23:32 +08:00
Junhan Chang
bd51c74fab fix converter in resource_tracker 2025-10-18 16:22:30 +08:00
ZiWei
ba81cbddf8 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-10-18 10:51:04 +08:00
ZiWei
4e92a26057 fix(reaction_station): 清空工作流序列和参数避免重复执行 (#113)
在创建任务后清空工作流序列和参数,防止下次执行时累积重复
2025-10-17 13:41:50 +08:00
ZiWei
c2895bb197 fix(reaction_station): 清空工作流序列和参数避免重复累积 2025-10-17 13:13:54 +08:00
ZiWei
0423f4f452 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-10-17 13:00:32 +08:00
Junhan Chang
41390fbef9 fix resource_get in action 2025-10-17 11:18:47 +08:00
ZiWei
98bdb4e7e4 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-10-17 03:06:04 +08:00
Xuwznln
30037a077a correct return message 2025-10-17 03:03:08 +08:00
ZiWei
6972680099 Refactor Bioyond workstation and experiment workflow -fix (#111)
* refactor(bioyond_studio): 优化材料缓存加载和参数验证逻辑

改进材料缓存加载逻辑以支持多种材料类型和详细材料处理
更新工作流参数验证中的字段名从key/value改为Key/DisplayValue
移除未使用的merge_workflow_with_parameters方法
添加get_station_info方法获取工作站基础信息
清理实验文件中的注释代码和更新导入路径

* fix: 修复资源移除时的父资源检查问题

在BaseROS2DeviceNode中,移除资源前添加对父资源是否为None的检查,避免空指针异常
同时更新Bottle和BottleCarrier类以支持**kwargs参数
修正测试文件中Liquid_feeding_beaker的大小写拼写错误
2025-10-17 02:59:58 +08:00
ZiWei
9d2c93807d Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-10-17 02:40:33 +08:00
Xuwznln
e728007bc5 cancel upload_registry 2025-10-17 02:34:59 +08:00
ZiWei
9c5ecda7cc Refactor Bioyond workstation and experiment workflow (#110)
Refactored the Bioyond workstation classes to improve parameter handling and workflow management. Updated experiment.py to use BioyondReactionStation with deck and material mappings, and enhanced workflow step parameter mapping and execution logic. Adjusted JSON experiment configs, improved workflow sequence handling, and added UUID assignment to PLR materials. Removed unused station_config and material cache logic, and added detailed docstrings and debug output for workflow methods.
2025-10-17 02:32:13 +08:00
ZiWei
2d26c3fac6 fix: 修复资源移除时的父资源检查问题
在BaseROS2DeviceNode中,移除资源前添加对父资源是否为None的检查,避免空指针异常
同时更新Bottle和BottleCarrier类以支持**kwargs参数
修正测试文件中Liquid_feeding_beaker的大小写拼写错误
2025-10-17 02:23:58 +08:00
ZiWei
f5753afb7c refactor(bioyond_studio): 优化材料缓存加载和参数验证逻辑
改进材料缓存加载逻辑以支持多种材料类型和详细材料处理
更新工作流参数验证中的字段名从key/value改为Key/DisplayValue
移除未使用的merge_workflow_with_parameters方法
添加get_station_info方法获取工作站基础信息
清理实验文件中的注释代码和更新导入路径
2025-10-16 23:58:24 +08:00
Xuwznln
398b2dde3f Close #107
Update doc url.
2025-10-16 14:44:09 +08:00
Xuwznln
62c4135938 Update deploy-docs.yml 2025-10-16 14:28:55 +08:00
Xuwznln
027b4269c4 Update deploy-docs.yml 2025-10-16 14:23:22 +08:00
Xuwznln
3757bd9c58 fix state loading for regular container 2025-10-16 14:04:03 +08:00
Xuwznln
c75b7d5aae fix type conversion 2025-10-16 13:55:38 +08:00
Xuwznln
dfc635189c fix comprehensive_station.json 2025-10-16 13:52:07 +08:00
Xuwznln
d8f3ebac15 fix comprehensive_station.json 2025-10-16 13:46:59 +08:00
Xuwznln
4a1e703a3a support no size init 2025-10-16 13:35:59 +08:00
Xuwznln
55d22a7c29 Update regular container method 2025-10-16 13:33:28 +08:00
Xuwznln
03a4e4ecba fix to plr type error 2025-10-16 13:19:59 +08:00
Xuwznln
2316c34cb5 fix to plr type error 2025-10-16 13:12:21 +08:00
Xuwznln
a8887161d3 pack repo info 2025-10-16 13:06:13 +08:00
Xuwznln
25834f5ba0 provide error info when cant find plr type 2025-10-16 13:05:44 +08:00
Xuwznln
a1e9332b51 temp fix for resource get 2025-10-16 03:20:37 +08:00
Xuwznln
357fc038ef temp fix for resource get 2025-10-16 03:15:56 +08:00
Xuwznln
fd58ef07f3 Update boot example 2025-10-16 02:33:15 +08:00
Xuwznln
93dee2c1dc fix workstation node error 2025-10-16 01:59:48 +08:00
Xuwznln
70fbf19009 fix workstation node error 2025-10-16 01:58:15 +08:00
ZiWei
9149155232 Add logging configuration based on BasicConfig in main function 2025-10-14 21:02:15 +08:00
Xuwznln
1ca1792e3c mount parent uuid 2025-10-14 18:07:59 +08:00
Xuwznln
485e7e8dd2 Fix resource get.
Fix resource parent not found.
Mapping uuid for all resources.
2025-10-14 17:24:41 +08:00
ZiWei
4ddabdcb65 Refactor Bioyond workstation and experiment workflow (#105)
Refactored the Bioyond workstation classes to improve parameter handling and workflow management. Updated experiment.py to use BioyondReactionStation with deck and material mappings, and enhanced workflow step parameter mapping and execution logic. Adjusted JSON experiment configs, improved workflow sequence handling, and added UUID assignment to PLR materials. Removed unused station_config and material cache logic, and added detailed docstrings and debug output for workflow methods.
2025-10-14 02:46:31 +08:00
Xuwznln
a5b0325301 Tip more error log 2025-10-14 02:29:14 +08:00
Xuwznln
50b44938c7 Force confirm uuid 2025-10-14 02:22:39 +08:00
Xuwznln
df0d2235b0 Fix resource tree update 2025-10-14 01:55:29 +08:00
Xuwznln
4e434eeb97 Fix resource tree update 2025-10-14 01:53:04 +08:00
Xuwznln
ca027bf0eb Fix multiple resource error 2025-10-14 01:45:08 +08:00
Xuwznln
635a332b4e Fix workstation deck & children resource dupe 2025-10-14 00:21:37 +08:00
Xuwznln
edf7a117ca Fix workstation deck & children resource dupe 2025-10-14 00:21:16 +08:00
Xuwznln
70b2715996 Fix workstation resource not tracking 2025-10-14 00:05:41 +08:00
Xuwznln
7e8dfc2dc5 Fix children key error 2025-10-13 23:34:17 +08:00
Xuwznln
9b626489a8 Fix children key error 2025-10-13 21:20:42 +08:00
Xuwznln
03fe208743 Raise error when using unsupported type to create ResourceTreeSet 2025-10-13 15:20:30 +08:00
Xuwznln
e913e540a3 Fix ResourceTreeSet load error 2025-10-13 15:16:56 +08:00
Xuwznln
aed39b648d Fix workstation startup
Update registry
2025-10-13 15:01:46 +08:00
Xuwznln
8c8359fab3 Fix one-key installation build for windows 2025-10-13 15:01:46 +08:00
Xuwznln
5d20be0762 Fix conda pack on windows
(cherry picked from commit 2a8e8d014b)
2025-10-13 13:20:20 +08:00
Junhan Chang
09f745d300 modify default config 2025-10-13 10:49:15 +08:00
Junhan Chang
bbcbcde9a4 add plr_to_bioyond, and refactor bioyond stations 2025-10-13 09:41:43 +08:00
hh.(SII)
42b437cdea fix: rename schema field to resource_schema with serialization and validation aliases (#104)
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
2025-10-13 03:23:04 +08:00
Xuwznln
ffd0f2d26a Complete all one key installation 2025-10-13 03:21:16 +08:00
Xuwznln
32422c0b3d Install conda-pack before pack command 2025-10-13 03:09:44 +08:00
Xuwznln
c44e597dc0 Add conda-pack to base when building one-key installer 2025-10-13 03:01:48 +08:00
Xuwznln
4eef012a8e Fix param error when using mamba run 2025-10-13 02:50:33 +08:00
Xuwznln
ac69452f3c Try fix one-key build on linux 2025-10-13 02:35:06 +08:00
Xuwznln
57b30f627b Try fixx one-key build on linux 2025-10-13 02:24:03 +08:00
Xuwznln
2d2a4ca067 Try fix one-key build on linux
(cherry picked from commit eb1f3fbe1c)
2025-10-13 02:10:20 +08:00
Xuwznln
a2613aad4c fix startup env check.
add auto install during one-key installation
2025-10-13 01:35:28 +08:00
Xuwznln
54f75183ff clean files 2025-10-12 23:26:49 +08:00
Xuwznln
735be067dc fix ony-key script not exist 2025-10-12 23:10:06 +08:00
Xuwznln
0fe62d64f0 Update registry from pr 2025-10-12 23:04:25 +08:00
shiyubo0410
2d4ecec1e1 Update prcxi driver & fix transfer_liquid mix_times (#90)
* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py
2025-10-12 22:56:39 +08:00
lixinyu1011
0f976a1874 电池装配工站二次开发教程(带目录)上传至dev (#94)
* 电池装配工站二次开发教程

* Update intro.md

* 物料教程

* 更新物料教程,json格式注释
2025-10-12 22:56:14 +08:00
ZiWei
b263a7e679 Workshop bj (#99)
* Add LaiYu Liquid device integration and tests

Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included.

* feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档

refactor: 重新组织LaiYu_Liquid模块目录结构
docs: 添加SOPA移液器和步进电机控制指令文档
fix: 修正设备配置中的最大体积默认值
test: 新增工作台配置测试用例
chore: 删除过时的测试脚本和配置文件

* add

* 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用

- 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py
- 更新所有相关文件中的导入引用
- 保持代码功能不变,仅改善命名一致性
- 测试确认所有导入正常工作

* 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出

- 添加 LaiYuLiquidBackend 到导入列表
- 添加 LaiYuLiquidBackend 到 __all__ 导出列表
- 确保所有主要类都可以正确导入

* 修复大小写文件夹名字
2025-10-12 22:54:38 +08:00
Xuwznln
7c7f1b31c5 Bump version to 0.10.7 2025-10-12 22:52:34 +08:00
Xuwznln
00e668e140 Fix one-key installation path error 2025-10-12 22:49:51 +08:00
Xuwznln
4989f65a0b Fix nested conda pack 2025-10-12 22:45:05 +08:00
Xuwznln
9fa3688196 Update registry. Update uuid loop figure method. Update install docs. 2025-10-12 22:38:04 +08:00
Xuwznln
40fb1ea49c Merge branch 'main' into dev
# Conflicts:
#	.conda/recipe.yaml
#	.github/workflows/conda-pack-build.yml
#	recipes/msgs/recipe.yaml
#	recipes/unilabos/recipe.yaml
#	scripts/verify_installation.py
#	setup.py
#	unilabos/app/main.py
#	unilabos/app/mq.py
#	unilabos/app/register.py
#	unilabos/compile/heatchill_protocol.py
#	unilabos/compile/separate_protocol.py
#	unilabos/config/config.py
#	unilabos/devices/pump_and_valve/runze_backbone.py
#	unilabos/devices/pump_and_valve/runze_multiple_backbone.py
#	unilabos/registry/devices/characterization_chromatic.yaml
#	unilabos/registry/devices/liquid_handler.yaml
#	unilabos/registry/devices/pump_and_valve.yaml
#	unilabos/registry/devices/robot_arm.yaml
#	unilabos/registry/devices/robot_linear_motion.yaml
#	unilabos/registry/devices/work_station.yaml
#	unilabos/registry/registry.py
#	unilabos/registry/resources/organic/workstation.yaml
#	unilabos/resources/plr_additional_res_reg.py
#	unilabos/ros/nodes/base_device_node.py
#	unilabos/ros/nodes/presets/host_node.py
#	unilabos/ros/nodes/presets/workstation.py
#	unilabos/ros/nodes/resource_tracker.py
#	unilabos/utils/environment_check.py
#	unilabos_msgs/package.xml
2025-10-12 22:13:49 +08:00
Xuwznln
18b0bb397e Update recipe.yaml 2025-10-12 22:12:46 +08:00
Xuwznln
65abc5dbf7 Fix environment_check.py 2025-10-12 21:55:34 +08:00
Xuwznln
2455ca15ba Fix unilabos msgs search error 2025-10-12 21:39:06 +08:00
Xuwznln
05a3ff607a Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined> 2025-10-12 21:23:29 +08:00
Xuwznln
ec882df36d Fix FileNotFoundError 2025-10-12 21:00:18 +08:00
Xuwznln
43b992e3eb Update conda-pack-build.yml 2025-10-12 20:47:06 +08:00
Xuwznln
6422fa5a9a Update conda-pack-build.yml (with mamba) 2025-10-12 20:34:49 +08:00
Xuwznln
434b9e98e0 Update conda-pack-build.yml 2025-10-12 20:28:38 +08:00
Xuwznln
040073f430 Add version in __init__.py
Update conda-pack-build.yml
Add create_zip_archive.py
2025-10-12 20:28:04 +08:00
Xuwznln
3d95c9896a update conda-pack-build.yml 2025-10-12 19:41:34 +08:00
Xuwznln
9aa97ed01e update conda-pack-build.yml 2025-10-12 19:38:11 +08:00
Xuwznln
0b8bdf5e0a update conda-pack-build.yml 2025-10-12 19:34:16 +08:00
Xuwznln
299f010754 update conda-pack-build.yml 2025-10-12 19:27:25 +08:00
Xuwznln
15ce0d6883 update conda-pack-build.yml 2025-10-12 19:04:48 +08:00
Xuwznln
dec474e1a7 add auto install script for conda-pack-build.yml
(cherry picked from commit 172599adcf)
2025-10-12 18:53:10 +08:00
Xuwznln
5f187899fc add conda-pack-build.yml 2025-10-12 17:27:59 +08:00
Xuwznln
c8d16c7024 update todo 2025-10-11 13:53:17 +08:00
Junhan Chang
25d46dc9d5 pass the tests 2025-10-11 07:20:34 +08:00
Junhan Chang
88c4d1a9d1 modify bioyond/plr converter, bioyond resource registry, and tests 2025-10-11 04:59:59 +08:00
Xuwznln
81fd8291c5 update todo 2025-10-11 03:38:59 +08:00
Xuwznln
3a11eb90d4 feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了 2025-10-11 03:38:14 +08:00
Xuwznln
387866b9c9 修复同步任务报错不显示的bug 2025-10-11 03:14:12 +08:00
Xuwznln
7f40f141f6 移动内部action以兼容host node 2025-10-11 03:11:17 +08:00
Xuwznln
6fc7ed1b88 过滤本地动作 2025-10-11 03:06:37 +08:00
Xuwznln
93f0e08d75 fix host_node test_resource error 2025-10-11 03:04:15 +08:00
Xuwznln
4b43734b55 fix host_node test_resource error 2025-10-11 02:57:14 +08:00
Xuwznln
174b1914d4 fix host_node error 2025-10-11 02:54:15 +08:00
Xuwznln
704e13f030 新增test_resource动作 2025-10-11 02:53:18 +08:00
Xuwznln
0c42d60cf2 更新transfer_resource_to_another参数,支持spot入参 2025-10-11 02:41:37 +08:00
Xuwznln
df33e1a214 修复transfer_resource_to_another生成 2025-10-11 01:12:56 +08:00
Xuwznln
1f49924966 修复资源添加 2025-10-11 00:58:56 +08:00
Xuwznln
609b6006e8 支持选择器注册表自动生成
支持转运物料
2025-10-11 00:57:22 +08:00
Xuwznln
67c01271b7 add update remove 2025-10-10 20:15:16 +08:00
Xuwznln
a1783f489e Merge remote-tracking branch 'origin/workstation_dev_YB2' into dev
# Conflicts:
#	unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py
#	unilabos/devices/workstation/bioyond_studio/station.py
#	unilabos/resources/graphio.py
2025-10-10 15:38:45 +08:00
Xuwznln
a8f6527de9 修复to_plr_resources 2025-10-10 15:30:26 +08:00
ZiWei
54cfaf15f3 Workstation dev yb2 (#100)
* Refactor and extend reaction station action messages

* Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities

- Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability.
- Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input.
- Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input.
- Refined `create_diamine_solution_task` to include additional parameters for better task configuration.
- Enhanced schema descriptions and default values for improved user guidance.
2025-10-10 15:25:50 +08:00
Xuwznln
5610c28b67 更新物料接口 2025-10-10 07:13:59 +08:00
Junhan Chang
cfc1ee6e79 Workstation templates: Resources and its CRUD, and workstation tasks (#95)
* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
2025-09-30 17:23:13 +08:00
Junhan Chang
1c9d2ee98a fix bioyond resource io 2025-09-30 17:02:38 +08:00
Junhan Chang
3fe8f4ca44 add child_size for itemized_carrier 2025-09-30 12:58:42 +08:00
Junhan Chang
2476821dcc update bioyond launch json 2025-09-30 12:25:21 +08:00
Junhan Chang
7b426ed5ae create warehouse by factory func 2025-09-30 11:57:34 +08:00
Junhan Chang
9bbae96447 Merge branch 'workstation_dev_YB2' of https://github.com/dptech-corp/Uni-Lab-OS into workstation_dev_YB2 2025-09-29 21:02:05 +08:00
Junhan Chang
10aabb7592 refactor: add itemized_carrier instead of carrier consists of ResourceHolder 2025-09-29 20:36:45 +08:00
Junhan Chang
709eb0d91c Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-09-27 00:10:59 +08:00
Junhan Chang
14b7d52825 create/update resources with POST/PUT for big amount/ small amount data 2025-09-26 23:25:50 +08:00
Junhan Chang
a5397ffe12 create/update resources with POST/PUT for big amount/ small amount data 2025-09-26 23:25:34 +08:00
LccLink
c6c2da69ba frontend_docs 2025-09-26 23:20:22 +08:00
Junhan Chang
622e579063 fix: update resource with correct structure; remove deprecated liquid_handler set_group action 2025-09-26 20:24:15 +08:00
Junhan Chang
196e0f7e2b fix bioyond station and registry 2025-09-26 08:12:41 +08:00
Junhan Chang
a632fd495e bioyond station with communication init and resource sync 2025-09-25 20:56:29 +08:00
Junhan Chang
a8cc02a126 add bioyond studio draft 2025-09-25 20:36:52 +08:00
Xie Qiming
ad2e1432c6 feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)
* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2
2025-09-25 18:53:04 +08:00
Junhan Chang
c3b9583eac fix: update resource with correct structure; remove deprecated liquid_handler set_group action 2025-09-25 15:27:05 +08:00
Junhan Chang
5c47cd0c8a add BIOYOND deck assignment and pass all tests 2025-09-25 08:41:41 +08:00
Junhan Chang
63ab1af45d refactor and add BIOYOND resources tests 2025-09-25 08:14:48 +08:00
Junhan Chang
a8419dc0c3 add standardized BIOYOND resources: bottle_carrier, bottle 2025-09-25 03:49:07 +08:00
Junhan Chang
34f05f2e25 refactor: rename "station_resource" to "deck" 2025-09-24 10:53:11 +08:00
h840473807
0dc2488f02 coin_cell_station draft 2025-09-23 01:18:04 +08:00
Junhan Chang
f13156e792 fix dict to tree/nested-dict converter 2025-09-23 00:02:45 +08:00
Xuwznln
13fd1ac572 更新物料接口 2025-09-22 17:14:48 +08:00
Guangxin Zhang
f8ef6e0686 Add Defaultlayout 2025-09-19 19:34:25 +01:00
Xuwznln
94a7b8aaca Update install md 2025-09-19 23:02:46 +08:00
Xuwznln
301bea639e 修复protocolnode的兼容性 2025-09-19 22:54:27 +08:00
Xuwznln
4b5a83efa4 修复protocolnode的兼容性 2025-09-19 21:09:07 +08:00
Xuwznln
2889e9be2c 更新所有注册表 2025-09-19 20:28:43 +08:00
Xuwznln
304aebbba7 bump version to 0.10.6 2025-09-19 19:55:34 +08:00
Xuwznln
091c9fa247 Merge branch 'workstation_dev' into dev
# Conflicts:
#	.conda/recipe.yaml
#	recipes/msgs/recipe.yaml
#	recipes/unilabos/recipe.yaml
#	setup.py
#	unilabos/registry/devices/work_station.yaml
#	unilabos/ros/nodes/base_device_node.py
#	unilabos/ros/nodes/presets/protocol_node.py
#	unilabos_msgs/package.xml
2025-09-19 19:52:53 +08:00
Xuwznln
67ca45a240 remove class for resource 2025-09-19 19:33:28 +08:00
Xuwznln
7aab2ea493 fix resource download 2025-09-19 19:17:03 +08:00
Xuwznln
62f3a6d696 PRCXI9320 json 2025-09-19 17:14:43 +08:00
Xuwznln
eb70ad0e18 PRCXI9320 json 2025-09-19 16:52:12 +08:00
Xuwznln
768f43880e PRCXI9320 json 2025-09-19 16:29:18 +08:00
Xuwznln
762c3c737c 重新补全zhida注册表 2025-09-19 11:45:57 +08:00
Xie Qiming
ace98a4472 Feature/xprbalance-zhida (#80)
* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance
2025-09-19 11:43:25 +08:00
Xuwznln
41eaa88c6f 修复移液站错误的aspirate注册表 2025-09-19 07:05:09 +08:00
Xuwznln
a1a55a2c0a fix resource_add 2025-09-19 06:25:28 +08:00
Xuwznln
2eaa0ca729 try fix add protocol 2025-09-19 06:21:29 +08:00
Xuwznln
6f8f070f40 fix protocol node log_message, added create_resource return value 2025-09-19 05:36:47 +08:00
Xuwznln
da4bd927e0 fix protocol node log_message, added create_resource return value 2025-09-19 05:31:49 +08:00
Xuwznln
01f8816597 update registry with nested obj 2025-09-19 03:44:18 +08:00
Guangxin Zhang
e5006285df 重新规定了版位推荐的入参 2025-09-18 15:27:22 +01:00
Guangxin Zhang
573c724a5c 新增版位推荐功能 2025-09-17 21:07:19 +01:00
Xuwznln
09549d2839 resource_update use resource_add 2025-09-18 03:47:26 +08:00
Junhan Chang
50c7777cea Fix: run-column with correct vessel id (#86)
* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)
2025-09-16 14:40:16 +08:00
Xuwznln
4888f02c09 add server timeout 2025-09-16 09:47:06 +08:00
Xuwznln
779c9693d9 refactor ws client 2025-09-16 05:24:42 +08:00
Xuwznln
ffa841a41a fix dupe upload registry 2025-09-15 16:25:41 +08:00
Xuwznln
fc669f09f8 fix import error 2025-09-15 15:55:44 +08:00
Xuwznln
2ca0311de6 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5 2025-09-15 02:39:43 +08:00
Guangxin Zhang
94cdcbf24e 对于PRCXI9320的transfer_group,一对多和多对多 2025-09-15 00:29:16 +08:00
Xuwznln
1cd07915e7 Correct runze pump multiple receive method. 2025-09-14 03:17:50 +08:00
Xuwznln
b600fc666d Correct runze pump multiple receive method. 2025-09-14 03:07:48 +08:00
Xuwznln
9e214c56c1 Update runze_multiple_backbone 2025-09-14 01:04:50 +08:00
Xuwznln
bdf27a7e82 Correct runze multiple backbone 2025-09-14 00:40:29 +08:00
Xuwznln
2493fb9f94 Update runze pump format 2025-09-14 00:22:39 +08:00
Xuwznln
c7a0ff67a9 support multiple backbone
(cherry picked from commit 4771ff2347)
2025-09-14 00:21:54 +08:00
Xuwznln
711a7c65fa remove runze multiple software obtainer
(cherry picked from commit 8bcc92a394)
2025-09-14 00:21:53 +08:00
Xuwznln
cde7956896 runze multiple pump support
(cherry picked from commit 49354fcf39)
2025-09-14 00:21:52 +08:00
Xuwznln
95b6fd0451 新增uat的地址替换 2025-09-11 16:38:17 +08:00
Xuwznln
513e848d89 result_info改为字典类型 2025-09-11 16:24:53 +08:00
Guangxin Zhang
58d1cc4720 Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml 2025-09-10 21:23:15 +08:00
Guangxin Zhang
5676dd6589 Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists 2025-09-10 20:57:22 +08:00
Guangxin Zhang
1ae274a833 Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup
- Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes.
- Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume.
- Both actions include response fields for return information and success status.
2025-09-10 20:57:16 +08:00
Xuwznln
22b88c8441 取消labid 和 强制config输入 2025-09-10 20:55:24 +08:00
Xuwznln
81bcc1907d fix: addr param 2025-09-10 20:14:33 +08:00
Xuwznln
8cffd3dc21 fix: addr param 2025-09-10 20:13:44 +08:00
Xuwznln
a722636938 增加addr参数 2025-09-10 20:01:10 +08:00
Xuwznln
f68340d932 修复status密集发送时,消息出错 2025-09-10 18:52:23 +08:00
Xuwznln
361eae2f6d 注册表编辑器 2025-09-07 20:57:48 +08:00
Xuwznln
c25283ae04 主机节点信息等支持自动刷新 2025-09-07 12:53:00 +08:00
Xuwznln
961752fb0d 更新schema的title字段 2025-09-07 00:43:23 +08:00
Xuwznln
55165024dd 修复async错误 2025-09-04 20:19:15 +08:00
Xuwznln
6ddceb8393 修复edge上报错误 2025-09-04 19:31:19 +08:00
Xuwznln
4e52c7d2f4 修复event loop错误 2025-09-04 17:11:50 +08:00
Xuwznln
0b56efc89d 增加handle检测,增加material edge关系上传 2025-09-04 16:46:25 +08:00
Xuwznln
a27b93396a 修复工站的tracker实例追踪失效问题 2025-09-04 02:51:13 +08:00
Xuwznln
2a60a6c27e 修正物料关系上传 2025-09-03 14:20:37 +08:00
Xuwznln
5dda94044d 增加物料关系上传日志 2025-09-03 12:31:25 +08:00
Xuwznln
0cfc6f45e3 增加物料关系上传日志 2025-09-03 12:20:54 +08:00
Xuwznln
831f4549f9 ws protocol 2025-09-02 18:51:27 +08:00
Xuwznln
f4d4eb06d3 ws test version 2 2025-09-02 18:29:05 +08:00
Xuwznln
e3b8164f6b ws test version 1 2025-09-02 14:32:02 +08:00
Xuwznln
78c04acc2e fix: missing job_id key 2025-09-01 16:34:23 +08:00
Xuwznln
cd0428ea78 fix: build 2025-08-30 12:24:28 +08:00
Xuwznln
bdddbd57ba fix: 还原protocol node处理方法 2025-08-30 12:22:46 +08:00
Xuwznln
a312de08a5 fix: station自己的方法注册错误 2025-08-30 12:20:24 +08:00
Xuwznln
68513b5745 feat: action status 2025-08-29 15:38:16 +08:00
Xuwznln
19027350fb feat: workstation example 2025-08-29 02:47:20 +08:00
Xuwznln
bbbdb06bbc feat: websocket test 2025-08-28 19:57:14 +08:00
Xuwznln
cd84e26126 feat: websocket 2025-08-28 14:34:38 +08:00
Junhan Chang
ce5bab3af1 example for use WorkstationBase 2025-08-27 15:20:20 +08:00
Junhan Chang
82d9ef6bf7 uncompleted refactor 2025-08-27 15:19:58 +08:00
Junhan Chang
332b33c6f4 simplify resource system 2025-08-27 11:13:56 +08:00
ZiWei
1ec642ee3a update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84)
* Add:msgs.action

* update: 将版本号从 0.10.3 更新为 0.10.4
2025-08-27 01:55:28 +08:00
ZiWei
7d8e6d029b Add:msgs.action (#83) 2025-08-27 01:21:13 +08:00
Junhan Chang
5ec8a57a1f refactor: ProtocolNode→WorkstationNode 2025-08-25 22:09:37 +08:00
Junhan Chang
ae3c1100ae refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode 2025-08-22 06:43:43 +08:00
Junhan Chang
14bc2e6cda Create workstation_architecture.md 2025-08-21 10:09:57 +08:00
Junhan Chang
9f823a4198 update workstation base 2025-08-21 10:05:58 +08:00
Xuwznln
02c79363c1 feat: add sk & ak 2025-08-20 21:23:08 +08:00
Junhan Chang
227ff1284a add workstation template and battery example 2025-08-19 21:35:27 +08:00
Xuwznln
4b7bde6be5 Update recipe.yaml 2025-08-13 16:36:53 +08:00
Xuwznln
8a669ac35a fix: figure_resource 2025-08-13 13:23:02 +08:00
Junhan Chang
a1538da39e use call_async in all service to avoid deadlock 2025-08-13 04:25:51 +08:00
Xuwznln
0063df4cf3 fix: prcxi import error 2025-08-12 19:31:52 +08:00
Xuwznln
e570ba4976 临时兼容错误的driver写法 2025-08-12 19:20:53 +08:00
Xuwznln
e8c1f76dbb fix protocol node 2025-08-12 17:08:59 +08:00
Junhan Chang
f791c1a342 fix filter protocol 2025-08-12 16:48:32 +08:00
Junhan Chang
ea60cbe891 bugfixes on organic protocols 2025-08-12 14:50:01 +08:00
Junhan Chang
eac9b8ab3d fix and remove redundant info 2025-08-11 20:52:03 +08:00
Xuwznln
573bcf1a6c feat: 新增use_remote_resource参数 2025-08-11 16:09:27 +08:00
Junhan Chang
50e93cb1af fix all protocol_compilers and remove deprecated devices 2025-08-11 15:01:04 +08:00
Xuwznln
fe1a029a9b feat: 优化protocol node节点运行日志 2025-08-10 17:31:44 +08:00
Junhan Chang
662c063f50 fix pumps and liquid_handler handle 2025-08-07 20:59:57 +08:00
Xuwznln
01cbbba0b3 feat: workstation example 2025-08-07 15:26:17 +08:00
Xuwznln
e6c556cf19 add: prcxi res
fix: startup slow
2025-08-07 01:26:33 +08:00
Xuwznln
0605f305ed fix: prcxi_res 2025-08-06 23:06:22 +08:00
Xuwznln
37d8108ec4 fix: discard_tips 2025-08-06 19:27:10 +08:00
Xuwznln
6081dac561 fix: discard_tips error 2025-08-06 19:18:35 +08:00
Xuwznln
5b2d066127 fix: drop_tips not using auto resource select 2025-08-06 19:10:04 +08:00
ZiWei
06e66765e7 feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79) 2025-08-06 18:49:37 +08:00
Xuwznln
98ce360088 feat: add trace log level 2025-08-04 20:27:02 +08:00
Xuwznln
5cd0f72fbd modify default discovery_interval to 15s 2025-08-04 14:10:43 +08:00
Xuwznln
343f394203 fix: working dir error when input config path
feat: report publish topic when error
2025-08-04 14:04:31 +08:00
Junhan Chang
46aa7a7bd2 fix: workstation handlers and vessel_id parsing 2025-08-04 10:24:42 +08:00
Junhan Chang
a66369e2c3 Cleanup registry to be easy-understanding (#76)
* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
2025-08-03 11:21:37 +08:00
81 changed files with 10293 additions and 3496 deletions

View File

@@ -0,0 +1,160 @@
---
name: add-device
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
---
# 添加新设备到 Uni-Lab-OS
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
---
## 装饰器参考
### @device — 设备类装饰器
```python
from unilabos.registry.decorators import device
# 单设备
@device(
id="my_device.vendor", # 注册表唯一标识(必填)
category=["temperature"], # 分类标签列表(必填)
description="设备描述", # 设备描述
display_name="显示名称", # UI 显示名称(默认用 id
icon="DeviceIcon.webp", # 图标文件名
version="1.0.0", # 版本号
device_type="python", # "python" 或 "ros2"
handles=[...], # 端口列表InputHandle / OutputHandle
model={...}, # 3D 模型配置
hardware_interface=HardwareInterface(...), # 硬件通信接口
)
# 多设备(同一个类注册多个设备 ID各自有不同的 handles 等配置)
@device(
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
id_meta={
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
},
category=["pump_and_valve"],
)
```
### @action — 动作方法装饰器
```python
from unilabos.registry.decorators import action
@action # 无参:注册为 UniLabJsonCommand 动作
@action() # 同上
@action(description="执行操作") # 带描述
@action(
action_type=HeatChill, # 指定 ROS Action 消息类型
goal={"temperature": "temp"}, # Goal 字段映射
feedback={}, # Feedback 字段映射
result={}, # Result 字段映射
handles=[...], # 动作级别端口
goal_default={"temp": 25.0}, # Goal 默认值
placeholder_keys={...}, # 参数占位符
always_free=True, # 不受排队限制
auto_prefix=True, # 强制使用 auto- 前缀
parent=True, # 从父类 MRO 获取参数签名
)
```
**自动识别规则:**
-`@action` 的公开方法 → 注册为动作(方法名即动作名)
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
- `_` 开头的方法 → 不扫描
- `@not_action` 标记的方法 → 排除
### @topic_config — 状态属性配置
```python
from unilabos.registry.decorators import topic_config
@property
@topic_config(
period=5.0, # 发布周期(秒),默认 5.0
print_publish=False, # 是否打印发布日志
qos=10, # QoS 深度,默认 10
name="custom_name", # 自定义发布名称(默认用属性名)
)
def temperature(self) -> float:
return self.data.get("temperature", 0.0)
```
### 辅助装饰器
```python
from unilabos.registry.decorators import not_action, always_free
@not_action # 标记为非动作post_init、辅助方法等
@always_free # 标记为不受排队限制(查询类操作)
```
---
## 设备模板
```python
import logging
from typing import Any, Dict, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.registry.decorators import device, action, topic_config, not_action
@device(id="my_device", category=["my_category"], description="设备描述")
class MyDevice:
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
self.device_id = device_id or "my_device"
self.config = config or {}
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.data: Dict[str, Any] = {"status": "Idle"}
@not_action
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
self._ros_node = ros_node
@action
async def initialize(self) -> bool:
self.data["status"] = "Ready"
return True
@action
async def cleanup(self) -> bool:
self.data["status"] = "Offline"
return True
@action(description="执行操作")
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
return {"success": True}
def get_info(self) -> Dict[str, Any]:
"""无 @action → 自动注册为 'auto-get_info' 动作"""
return {"device_id": self.device_id}
@property
@topic_config()
def status(self) -> str:
return self.data.get("status", "Idle")
@property
@topic_config(period=2.0)
def temperature(self) -> float:
return self.data.get("temperature", 0.0)
```
### 要点
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
- `post_init``@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
- 运行时状态存储在 `self.data` 字典中
- 设备文件放在 `unilabos/devices/<category>/` 目录下

View File

@@ -0,0 +1,351 @@
---
name: add-resource
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
---
# 添加新物料资源
Uni-Lab-OS 的资源体系基于 PyLabRobot通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册AST 自动扫描生成注册表条目。
---
## 资源类型
| 类型 | 基类 | 用途 | 示例 |
|------|------|------|------|
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle | 6 位试剂架、枪头盒 |
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier | 4x4 堆栈 |
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse | 反应站 Deck |
**层级关系:** `Deck``WareHouse``BottleCarrier``Bottle`
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位slot只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
---
## @resource 装饰器
```python
from unilabos.registry.decorators import resource
@resource(
id="my_resource_id", # 注册表唯一标识(必填)
category=["bottles"], # 分类标签列表(必填)
description="资源描述",
icon="", # 图标
version="1.0.0",
handles=[...], # 端口列表InputHandle / OutputHandle
model={...}, # 3D 模型配置
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
)
```
---
## 创建规范
### 命名规则
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
2. **Bottle 命名约定**:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
### 子物料命名示例
```python
# Carrier 内部的 sites 用 name 前缀
for k, v in sites.items():
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
# Carrier 中放置 Bottle 时用 name 前缀
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
# create_homogeneous_resources 使用 name_prefix
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[...],
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
)
# Deck setup 中用仓库名称作为 name 传入
self.warehouses = {
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
}
```
### 其他规范
- **max_volume 单位为 μL**500mL = 500000
- **尺寸单位为 mm**`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/`
- **`__init__` 必须接受 `serialize()` 输出的所有字段**`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
---
## 资源模板
### Bottle
```python
from unilabos.registry.decorators import resource
from unilabos.resources.itemized_carrier import Bottle
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
def My_Reagent_Bottle(
name: str,
diameter: float = 70.0,
height: float = 120.0,
max_volume: float = 500000.0,
barcode: str = None,
) -> Bottle:
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="My_Reagent_Bottle",
)
```
**Bottle 参数:**
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
- `diameter`: 瓶体直径 (mm)
- `height`: 瓶体高度 (mm)
- `max_volume`: 最大容积(**μL**500mL = 500000
- `barcode`: 条形码(可选)
### BottleCarrier
```python
from pylabrobot.resources import ResourceHolder
from pylabrobot.resources.carrier import create_ordered_items_2d
from unilabos.resources.itemized_carrier import BottleCarrier
from unilabos.registry.decorators import resource
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
def My_6SlotCarrier(name: str) -> BottleCarrier:
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3, num_items_y=2,
dx=10.0, dy=10.0, dz=5.0,
item_dx=42.0, item_dy=35.0,
size_x=20.0, size_y=20.0, size_z=50.0,
)
# 子 site 用 name 作为前缀
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
sites=sites, model="My_6SlotCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
# 放置 Bottle 时用 name 作为前缀
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
for i in range(6):
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
return carrier
```
### WareHouse / Deck 放置位
WareHouse 和 Site 本质上是同一概念都是定义一组固定放置位slot根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
#### WareHouse使用 warehouse_factory
```python
from unilabos.resources.warehouse import warehouse_factory
from unilabos.registry.decorators import resource
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
def my_warehouse_4x4(name: str) -> "WareHouse":
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
model="my_warehouse_4x4",
col_offset=0, # 列标签起始偏移0 → A01, 4 → A05
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
)
```
`warehouse_factory` 参数说明:
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移mm
- `item_dx/item_dy/item_dz`:相邻 slot 间距mm需根据实际物理间距测量
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
- `layout`:影响 slot 标签和坐标映射
- `"row-major"`A01,A02,...,B01,B02,...(行优先,适合横向排列)
- `"col-major"`A01,B01,...,A02,B02,...(列优先)
- `"vertical-col-major"`竖向排列y 坐标反向
#### Deck 组装 WareHouse
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
```python
from pylabrobot.resources import Deck, Coordinate
from unilabos.registry.decorators import resource
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
class MyStation_Deck(Deck):
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
category="deck", setup=False, **kwargs) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
if setup:
self.setup()
def setup(self) -> None:
self.warehouses = {
"堆栈1左": my_warehouse_4x4("堆栈1左"),
"堆栈1右": my_warehouse_4x4("堆栈1右"),
}
self.warehouse_locations = {
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
}
for wh_name, wh in self.warehouses.items():
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
```
#### Site 模式(前端定向放置)
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
```python
import collections
from typing import Any, Dict, List, Optional
from pylabrobot.resources import Deck, Resource, Coordinate
from unilabos.registry.decorators import resource
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
class MyLabDeck(Deck):
# 根据设备台面实测批量计算各 slot 坐标偏移
_DEFAULT_SITE_POSITIONS = [
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
]
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
super().__init__(size_x, size_y, size_z, name)
if sites is not None:
self.sites = [dict(s) for s in sites]
else:
self.sites = []
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
self.sites.append({
"label": f"T{i + 1}", # 前端显示的槽位标签
"visible": True, # 是否在前端可见
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
})
self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites
)
def assign_child_resource(self, resource: Resource,
location: Optional[Coordinate] = None,
reassign: bool = True,
spot: Optional[int] = None):
idx = spot
if spot is None:
for i, site in enumerate(self.sites):
if site.get("label") == resource.name:
idx = i
break
if idx is None:
for i in range(len(self.sites)):
if self._get_site_resource(i) is None:
idx = i
break
if idx is None:
raise ValueError(f"No available site for '{resource.name}'")
loc = Coordinate(**self.sites[idx]["position"])
super().assign_child_resource(resource, location=loc, reassign=reassign)
def serialize(self) -> dict:
data = super().serialize()
sites_out = []
for i, site in enumerate(self.sites):
occupied = self._get_site_resource(i)
sites_out.append({
"label": site["label"],
"visible": site.get("visible", True),
"occupied_by": occupied.name if occupied else None,
"position": site["position"],
"size": site["size"],
"content_type": site["content_type"],
})
data["sites"] = sites_out
return data
```
**Site 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
| `visible` | bool | 是否在前端可见 |
| `position` | dict | 物理坐标 `{x, y, z}`mm需自行测量计算偏移 |
| `size` | dict | 槽位尺寸 `{width, height, depth}`mm |
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`4x4 共 16 个 site
---
## 文件位置
```
unilabos/resources/
├── <project>/ # 按项目分组
│ ├── bottles.py # Bottle 工厂函数
│ ├── bottle_carriers.py # Carrier 工厂函数
│ ├── warehouses.py # WareHouse 工厂函数
│ └── decks.py # Deck 类定义
```
---
## 验证
```bash
# 资源可导入
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
# 启动测试AST 自动扫描)
unilab -g <graph>.json
```
仅在以下情况仍需 YAML第三方库资源如 pylabrobot 内置资源,无 `@resource` 装饰器)。
---
## 关键路径
| 内容 | 路径 |
|------|------|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
| 装饰器定义 | `unilabos/registry/decorators.py` |

View File

@@ -0,0 +1,292 @@
# 资源高级参考
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
---
## 1. 类继承体系
```
PyLabRobot
├── Resource (PLR 基类)
│ ├── Well
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
│ ├── Deck
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
│ ├── ResourceHolder → 槽位占位符
│ └── Container
│ └── Battery (unilabos) → 组装好的电池
├── ItemizedCarrier (unilabos, 继承 Resource)
│ ├── BottleCarrier (unilabos) → 瓶载架
│ └── WareHouse (unilabos) → 堆栈仓库
├── ItemizedResource (PLR)
│ └── MagazineHolder (unilabos) → 子弹夹载架
└── ResourceStack (PLR)
└── Magazine (unilabos) → 子弹夹洞位
```
### Bottle 类细节
```python
class Bottle(Well):
def __init__(self, name, diameter, height, max_volume,
size_x=0.0, size_y=0.0, size_z=0.0,
barcode=None, category="container", model=None, **kwargs):
super().__init__(
name=name,
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
size_y=diameter,
size_z=height, # PLR 用 height 作为 size_z
max_volume=max_volume,
category=category,
model=model,
bottom_type="flat",
cross_section_type="circle"
)
```
注意 `size_x = size_y = diameter``size_z = height`
### ItemizedCarrier 核心方法
| 方法 | 说明 |
|------|------|
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
| `get_child_identifier(child)` | 获取子资源的标识符 |
| `capacity` | 总槽位数 |
| `sites` | 所有槽位字典 |
---
## 2. 序列化与反序列化
### PLR ↔ UniLab 转换
| 函数 | 位置 | 方向 |
|------|------|------|
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
### `from_plr_resources` 流程
```
PLR Resource
↓ build_uuid_mapping (递归生成 UUID)
↓ resource.serialize() → dict
↓ resource.serialize_all_state() → states
↓ resource_plr_inner (递归构建 ResourceDictInstance)
ResourceTreeSet
```
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID`unilabos_extra` 携带扩展数据(如 `class` 名)。
### `to_plr_resources` 流程
```
ResourceTreeSet
↓ collect_node_data (收集 UUID、状态、扩展数据)
↓ node_to_plr_dict (转为 PLR 字典格式)
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
↓ sub_cls.deserialize(plr_dict) (反序列化)
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
PLR Resource
```
### Bottle 序列化
```python
class Bottle(Well):
def serialize(self) -> dict:
data = super().serialize()
return {**data, "diameter": self.diameter, "height": self.height}
@classmethod
def deserialize(cls, data: dict, allow_marshal=False):
barcode_data = data.pop("barcode", None)
instance = super().deserialize(data, allow_marshal=allow_marshal)
if barcode_data and isinstance(barcode_data, str):
instance.barcode = barcode_data
return instance
```
---
## 3. Bioyond 物料同步
### 双向转换函数
| 函数 | 位置 | 方向 |
|------|------|------|
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
### `resource_bioyond_to_plr` 流程
```
Bioyond 物料列表
↓ reverse_type_mapping: {typeName → (model, UUID)}
↓ 对每个物料:
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
initialize_resource({"name": unique_name, "class": model})
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
↓ 处理 detail (子物料/坐标)
↓ 按 locationName 放入 deck.warehouses 对应槽位
PLR 资源列表
```
### `resource_plr_to_bioyond` 流程
```
PLR 资源列表
↓ 遍历每个资源:
载架(capacity > 1): 生成 details 子物料 + 坐标
单瓶: 直接映射
↓ type_mapping 查找 typeId
↓ warehouse_mapping 查找位置 UUID
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
Bioyond 物料列表
```
### BioyondResourceSynchronizer
工作站通过 `ResourceSynchronizer` 自动同步物料:
```python
class BioyondResourceSynchronizer(ResourceSynchronizer):
def sync_from_external(self) -> bool:
all_data = []
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
unilab_resources = resource_bioyond_to_plr(
all_data,
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
deck=self.workstation.deck
)
# 更新 deck 上的资源
```
---
## 4. 非瓶类资源
### ElectrodeSheet极片
路径:`unilabos/resources/battery/electrode_sheet.py`
```python
class ElectrodeSheet(ResourcePLR):
"""片状材料(极片、隔膜、弹片、垫片等)"""
_unilabos_state = {
"diameter": 0.0,
"thickness": 0.0,
"mass": 0.0,
"material_type": "",
"color": "",
"info": "",
}
```
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
### Battery电池
```python
class Battery(Container):
"""组装好的电池"""
_unilabos_state = {
"color": "",
"electrolyte_name": "",
"open_circuit_voltage": 0.0,
}
```
### Magazine / MagazineHolder子弹夹
```python
class Magazine(ResourceStack):
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
# direction, max_sheets
class MagazineHolder(ItemizedResource):
"""多洞位子弹夹"""
# hole_diameter, hole_depth, max_sheets_per_hole
```
工厂函数 `magazine_factory()``create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet``Battery`
---
## 5. 仓库工厂模式参考
### 实际 warehouse 工厂函数示例
```python
# 行优先 4x4 仓库
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
layout="row-major", # A01,A02,A03,A04, B01,...
)
# 右侧 4x4 仓库(列名偏移)
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
col_offset=4, # A05,A06,A07,A08
layout="row-major",
)
# 竖向仓库(站内试剂存放)
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=1, num_items_y=2, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
layout="vertical-col-major",
)
# 行偏移F 行开始)
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=3, num_items_y=5, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=159.0, item_dy=183.0, item_dz=130.0,
row_offset=row_offset, # 0→A行起5→F行起
layout="row-major",
)
```
### layout 类型说明
| layout | 命名顺序 | 适用场景 |
|--------|---------|---------|
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先Bioyond 前端展示 |
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
---
## 6. 关键路径
| 内容 | 路径 |
|------|------|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
| 电池资源 | `unilabos/resources/battery/` |
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |

View File

@@ -0,0 +1,626 @@
---
name: add-workstation
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
---
# Uni-Lab-OS 工作站接入指南
工作站workstation是组合多个子设备的大型设备拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册AST 自动扫描生成注册表。
---
## 工作站类型
| 类型 | 基类 | 适用场景 |
| ------------------- | ----------------- | ---------------------------------- |
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
---
## @device 装饰器(工作站)
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
```python
@device(
id="my_workstation", # 注册表唯一标识(必填)
category=["workstation"], # 分类标签
description="我的工作站",
)
```
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL
---
## 工作站驱动模板
### 模板 A基于外部系统的工作站
```python
import logging
from typing import Dict, Any, Optional
from pylabrobot.resources import Deck
from unilabos.registry.decorators import device, topic_config, not_action
from unilabos.devices.workstation.workstation_base import WorkstationBase
try:
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
except ImportError:
ROS2WorkstationNode = None
@device(id="my_workstation", category=["workstation"], description="我的工作站")
class MyWorkstation(WorkstationBase):
_ros_node: "ROS2WorkstationNode"
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
super().__init__(deck=deck, **kwargs)
self.config = config or {}
self.logger = logging.getLogger("MyWorkstation")
self.api_host = self.config.get("api_host", "")
self._status = "Idle"
@not_action
def post_init(self, ros_node: "ROS2WorkstationNode"):
super().post_init(ros_node)
self._ros_node = ros_node
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
"""注册为工作站动作"""
return {"success": True}
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
"""注册为工作站动作"""
return {"success": True}
@property
@topic_config()
def workflow_sequence(self) -> str:
return "[]"
@property
@topic_config()
def material_info(self) -> str:
return "{}"
```
### 模板 BProtocol 工作站
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
```python
from unilabos.devices.workstation.workstation_base import ProtocolNode
```
在图文件中配置 `protocol_type` 即可。
---
## 子设备访问sub_devices
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中key 为设备 idvalue 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
```python
# 在工站驱动类的方法中访问子设备
sub = self._ros_node.sub_devices["pump_1"]
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
sub.driver_instance.some_method(arg1, arg2)
# .ros_node_instance — 子设备的 ROS2 节点实例
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
```
**常见用法**
```python
class MyWorkstation(WorkstationBase):
def my_protocol(self, **kwargs):
# 获取子设备驱动实例
pump = self._ros_node.sub_devices["pump_1"].driver_instance
heater = self._ros_node.sub_devices["heater_1"].driver_instance
# 直接调用子设备方法
pump.aspirate(volume=100)
heater.set_temperature(80)
```
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
---
## 硬件通信接口hardware_interface
硬件控制型工作站通常需要通过串口Serial、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
### 工作原理
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id优先完成初始化创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
```
hardware_interface = d.ros_node_instance._hardware_interface
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
```
1.`name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
2. 从通信设备获取真正的 `read`/`write` 方法
3.`setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
因此:
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`
- **通信设备 id 必须以 `serial_``io_` 开头**(否则第一轮不会被识别为通信设备)
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
### HardwareInterface 参数说明
```python
from unilabos.registry.decorators import HardwareInterface
HardwareInterface(
name="hardware_interface", # __init__ 中接收通信实例的属性名
read="send_command", # 通信设备上暴露的读方法名
write="send_command", # 通信设备上暴露的写方法名
extra_info=["list_ports"], # 可选:额外暴露的方法
)
```
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
### 示例 1name="hardware_interface"
```python
from unilabos.registry.decorators import device, HardwareInterface
@device(
id="my_pump",
category=["pump_and_valve"],
hardware_interface=HardwareInterface(
name="hardware_interface",
read="send_command",
write="send_command",
),
)
class MyPump:
def __init__(self, port=None, address="1", **kwargs):
# name="hardware_interface" → 系统替换 self.hardware_interface
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
self.address = address
def send_command(self, command: str):
full_command = f"/{self.address}{command}\r\n"
self.hardware_interface.write(bytearray(full_command, "ascii"))
return self.hardware_interface.read_until(b"\n")
```
### 示例 2电磁阀name="io_device_port",自定义属性名)
```python
@device(
id="solenoid_valve",
category=["pump_and_valve"],
hardware_interface=HardwareInterface(
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
read="read_io_coil",
write="write_io_coil",
),
)
class SolenoidValve:
def __init__(self, io_device_port: str = None, **kwargs):
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
```
### Serial 通信设备class="serial"
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`
```python
from serial import Serial, SerialException
from threading import Lock
class ROS2SerialNode(BaseROS2DeviceNode):
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
self.port = port
self.baudrate = baudrate
self._hardware_interface = {
"name": "hardware_interface",
"write": "send_command",
"read": "read_data",
}
self._query_lock = Lock()
self.hardware_interface = Serial(baudrate=baudrate, port=port)
BaseROS2DeviceNode.__init__(
self, driver_instance=self, registry_name=registry_name,
device_id=device_id, status_types={}, action_value_mappings={},
hardware_interface=self._hardware_interface, print_publish=False,
)
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
def send_command(self, command: str):
with self._query_lock:
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
return self.hardware_interface.read_until(b"\n").decode()
def read_data(self):
with self._query_lock:
return self.hardware_interface.read_until(b"\n").decode()
```
在图文件中使用 `"class": "serial"` 即可创建串口代理:
```json
{
"id": "serial_pump",
"class": "serial",
"parent": "my_station",
"config": { "port": "COM7", "baudrate": 9600 }
}
```
### 图文件配置
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
```json
{
"nodes": [
{
"id": "my_station",
"class": "workstation",
"children": ["serial_pump", "pump_1", "pump_2"],
"config": { "protocol_type": ["PumpTransferProtocol"] }
},
{
"id": "serial_pump",
"class": "serial",
"parent": "my_station",
"config": { "port": "COM7", "baudrate": 9600 }
},
{
"id": "pump_1",
"class": "syringe_pump_with_valve.runze.SY03B-T08",
"parent": "my_station",
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
},
{
"id": "pump_2",
"class": "syringe_pump_with_valve.runze.SY03B-T08",
"parent": "my_station",
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
}
],
"links": [
{
"source": "pump_1",
"target": "serial_pump",
"type": "communication",
"port": { "pump_1": "port", "serial_pump": "port" }
},
{
"source": "pump_2",
"target": "serial_pump",
"type": "communication",
"port": { "pump_2": "port", "serial_pump": "port" }
}
]
}
```
### 通信协议速查
| 协议 | config 参数 | 依赖包 | 通信设备 class |
| -------------------- | ------------------------------ | ---------- | -------------------------- |
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
| TCP Socket | `host`, `port` | stdlib | 自定义 |
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
---
## Deck 与物料生命周期
### 1. Deck 入参与两种初始化模式
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__``deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面台面上抽象二级、三级子物料。
有两种初始化模式:
#### init 初始化(推荐)
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
```json
"config": {
"deck": {
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
"_resource_child_name": "PRCXI_Deck"
}
}
```
#### deserialize 初始化
`config.deck``data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
```json
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
}
}
}
```
没有特殊需求时推荐 init 初始化。
#### config.deck 字段说明
| 字段 | 说明 |
|------|------|
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName` |
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
#### 设备 __init__ 接收
```python
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
super().__init__(deck=deck, **kwargs)
# deck 已经是反序列化后的 Deck 实例
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
```
#### Deck 节点(图文件中)
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id
```json
{
"id": "PRCXI_Deck",
"parent": "PRCXI",
"type": "deck",
"class": "",
"children": [],
"config": {
"type": "PRCXI9300Deck",
"size_x": 542, "size_y": 374, "size_z": 0,
"category": "deck",
"sites": [...]
},
"data": {}
}
```
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
- `children` 初始为空时,由同步器或手动初始化填充
- `config.type` 填 Deck 类名
### 2. Deck 为空时自行初始化
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
```python
@not_action
def post_init(self, ros_node):
super().post_init(ros_node)
if self.deck and not self.deck.children:
self._initialize_default_deck()
def _initialize_default_deck(self):
from my_labware import My_TipRack, My_Plate
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
```
### 3. 物料双向同步
当工作站对接外部系统LIMS/MES需要实现 `ResourceSynchronizer` 处理双向物料同步:
```python
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
class MyResourceSynchronizer(ResourceSynchronizer):
def sync_from_external(self) -> bool:
"""从外部系统同步到 self.workstation.deck"""
external_data = self._query_external_materials()
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
for item in external_data:
cls = self._resolve_resource_class(item["type"])
resource = cls(name=item["name"], **item["params"])
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
return True
def sync_to_external(self, resource) -> bool:
"""将 UniLab 侧物料变更同步到外部系统"""
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
external_format = self._convert_to_external(resource)
return self._push_to_external(external_format)
def handle_external_change(self, change_info) -> bool:
"""处理外部系统主动推送的变更"""
return True
```
同步策略取决于业务场景:
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
- **以 UniLab 为准**UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
在工作站 `post_init` 中初始化同步器:
```python
@not_action
def post_init(self, ros_node):
super().post_init(ros_node)
self.resource_synchronizer = MyResourceSynchronizer(self)
self.resource_synchronizer.sync_from_external()
```
### 4. 序列化与持久化serialize / serialize_state
资源类需正确实现序列化,系统据此完成持久化和前端同步。
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
```python
class MyDeck(Deck):
def __init__(self, name, size_x, size_y, size_z,
sites=None, # serialize() 输出的字段
rotation=None, # serialize() 输出的字段
barcode=None, # serialize() 输出的字段
**kwargs): # 兜底:接受所有未知的 serialize 字段
super().__init__(size_x, size_y, size_z, name)
# ...
def serialize(self) -> dict:
data = super().serialize()
data["sites"] = [...] # 自定义字段
return data
```
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
```python
class MyPlate(Plate):
def __init__(self, name, size_x, size_y, size_z,
material_info=None, **kwargs):
super().__init__(name, size_x, size_y, size_z, **kwargs)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
def serialize_state(self) -> Dict[str, Any]:
data = super().serialize_state()
data.update(self._unilabos_state)
return data
```
关键要点:
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
- `_unilabos_state` 中只存可 JSON 序列化的基本类型str, int, float, bool, list, dict, None
### 5. 子物料自动同步
子物料Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
### 6. 图文件配置(参考 prcxi_9320_slim.json
```json
{
"nodes": [
{
"id": "my_station",
"type": "device",
"class": "my_workstation",
"config": {
"deck": {
"_resource_type": "unilabos.resources.my_module:MyDeck",
"_resource_child_name": "my_deck"
},
"host": "10.20.30.1",
"port": 9999
}
},
{
"id": "my_deck",
"parent": "my_station",
"type": "deck",
"class": "",
"children": [],
"config": {
"type": "MyLabDeck",
"size_x": 542,
"size_y": 374,
"size_z": 0,
"category": "deck",
"sites": [
{
"label": "T1",
"visible": true,
"occupied_by": null,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
}
]
},
"data": {}
}
],
"edges": []
}
```
Deck 节点要点:
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`
- `config.sites` 完整列出所有 site从 Deck 类的 `serialize()` 输出获取)
- `children` 初始为空(由同步器或手动初始化填充)
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
---
## 子设备
子设备按标准设备接入流程创建(参见 add-device SKILL使用 `@device` 装饰器。
子设备约束:
- 图文件中 `parent` 指向工作站 ID
- 在工作站 `children` 数组中列出
---
## 关键规则
1. **`__init__` 必须接受 `deck``**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
4. **外部同步实现 `ResourceSynchronizer`**`sync_from_external` / `sync_to_external`
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()``asyncio.sleep()`
8. **使用 `@not_action` 标记非动作方法**`post_init`, `initialize`, `cleanup`
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
---
## 验证
```bash
# 模块可导入
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
# 启动测试AST 自动扫描)
unilab -g <graph>.json
```
---
## 现有工作站参考
| 工作站 | 驱动类 | 类型 |
| -------------- | ----------------------------- | -------- |
| Protocol 通用 | `ProtocolNode` | Protocol |
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。

View File

@@ -0,0 +1,371 @@
# 工作站高级模式参考
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
Agent 在需要实现这些功能时按需阅读。
---
## 1. 外部系统集成模式
### 1.1 RPC 客户端
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
```python
from unilabos.device_comms.rpc import BaseRequest
class MySystemRPC(BaseRequest):
"""外部系统 RPC 客户端"""
def __init__(self, host: str, api_key: str):
super().__init__(host)
self.api_key = api_key
def _request(self, endpoint: str, data: dict = None) -> dict:
return self.post(
url=f"{self.host}/api/{endpoint}",
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
"data": data or {},
},
)
def query_status(self) -> dict:
return self._request("status/query")
def create_order(self, order_data: dict) -> dict:
return self._request("order/create", order_data)
```
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py``BioyondV1RPC`
### 1.2 HTTP 回调服务
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
```python
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
class MyWorkstation(WorkstationBase):
def __init__(self, config=None, deck=None, **kwargs):
super().__init__(deck=deck, **kwargs)
self.config = config or {}
http_cfg = self.config.get("http_service_config", {})
self._http_service_config = {
"host": http_cfg.get("http_service_host", "127.0.0.1"),
"port": http_cfg.get("http_service_port", 8080),
}
self.http_service = None
def post_init(self, ros_node):
super().post_init(ros_node)
self.http_service = WorkstationHTTPService(
workstation_instance=self,
host=self._http_service_config["host"],
port=self._http_service_config["port"],
)
self.http_service.start()
```
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
| 端点 | 调用的工作站方法 |
|------|-----------------|
| `/report/step_finish` | `process_step_finish_report(report_request)` |
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
| `/report/material_change` | `process_material_change_report(report_data)` |
| `/report/error_handling` | `handle_external_error(error_data)` |
实现对应方法即可接收回调:
```python
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
"""处理步骤完成报告"""
step_name = report_request.data.get("stepName")
return {"success": True, "message": f"步骤 {step_name} 已处理"}
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
"""处理订单完成报告"""
order_code = report_request.data.get("orderCode")
return {"success": True}
```
参考:`unilabos/devices/workstation/workstation_http_service.py`
### 1.3 连接监控
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
```python
class ConnectionMonitor:
def __init__(self, workstation, check_interval=30):
self.workstation = workstation
self.check_interval = check_interval
self._running = False
self._thread = None
def start(self):
self._running = True
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
self._thread.start()
def _monitor_loop(self):
while self._running:
try:
# 调用外部系统接口检测连接
self.workstation.hardware_interface.ping()
status = "online"
except Exception:
status = "offline"
time.sleep(self.check_interval)
```
参考:`unilabos/devices/workstation/bioyond_studio/station.py``ConnectionMonitor`
---
## 2. Config 结构模式
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
### 2.1 外部系统连接
```json
{
"api_host": "http://192.168.1.100:8080",
"api_key": "YOUR_API_KEY"
}
```
### 2.2 HTTP 回调服务
```json
{
"http_service_config": {
"http_service_host": "127.0.0.1",
"http_service_port": 8080
}
}
```
### 2.3 物料类型映射
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID。用于双向物料转换。
```json
{
"material_type_mappings": {
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
}
}
```
### 2.4 仓库映射
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
```json
{
"warehouse_mapping": {
"仓库名": {
"uuid": "warehouse-uuid",
"site_uuids": {
"A01": "site-uuid-A01",
"A02": "site-uuid-A02"
}
}
}
}
```
### 2.5 工作流映射
将内部工作流名映射到外部系统的工作流 ID。
```json
{
"workflow_mappings": {
"internal_workflow_name": "external-workflow-uuid"
}
}
```
### 2.6 物料默认参数
```json
{
"material_default_parameters": {
"NMP": {
"unit": "毫升",
"density": "1.03",
"densityUnit": "g/mL",
"description": "N-甲基吡咯烷酮"
}
}
}
```
---
## 3. 资源同步机制
### 3.1 ResourceSynchronizer
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`
```python
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
class MyResourceSynchronizer(ResourceSynchronizer):
def __init__(self, workstation, api_client):
super().__init__(workstation)
self.api_client = api_client
def sync_from_external(self) -> bool:
"""从外部系统拉取物料到 deck"""
external_materials = self.api_client.list_materials()
for material in external_materials:
plr_resource = self._convert_to_plr(material)
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
return True
def sync_to_external(self, plr_resource) -> bool:
"""将 deck 中的物料变更推送到外部系统"""
external_data = self._convert_from_plr(plr_resource)
self.api_client.update_material(external_data)
return True
def handle_external_change(self, change_info) -> bool:
"""处理外部系统推送的物料变更"""
return True
```
### 3.2 update_resource — 上传资源树到云端
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
```python
# 在 post_init 中上传初始 deck
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [self.deck]}
)
# 在动作方法中更新特定资源
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [updated_plate]}
)
```
---
## 4. 工作流序列管理
工作站通过 `workflow_sequence` 属性管理任务队列JSON 字符串形式)。
```python
class MyWorkstation(WorkstationBase):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._workflow_sequence = []
@property
def workflow_sequence(self) -> str:
"""返回 JSON 字符串ROS 自动发布"""
import json
return json.dumps(self._workflow_sequence)
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
"""添加工作流到队列"""
self._workflow_sequence.append({
"name": workflow_name,
"status": "pending",
"created_at": time.time(),
})
return {"success": True}
async def clear_workflows(self) -> Dict[str, Any]:
"""清空工作流队列"""
self._workflow_sequence = []
return {"success": True}
```
---
## 5. 站间物料转移
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
```python
async def transfer_materials_to_another_station(
self,
target_device_id: str,
transfer_groups: list,
**kwargs,
) -> Dict[str, Any]:
"""将物料转移到另一个工作站"""
target_node = self._children.get(target_device_id)
if not target_node:
# 通过 ROS 节点查找非子设备的目标站
pass
for group in transfer_groups:
resource = self.find_resource_by_name(group["resource_name"])
# 从本站 deck 移除
resource.unassign()
# 调用目标站的接收方法
# ...
return {"success": True, "transferred": len(transfer_groups)}
```
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
---
## 6. post_init 完整模式
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
```python
def post_init(self, ros_node):
super().post_init(ros_node)
# 1. 初始化外部系统客户端(此时 config 已可用)
self.rpc_client = MySystemRPC(
host=self.config.get("api_host"),
api_key=self.config.get("api_key"),
)
self.hardware_interface = self.rpc_client
# 2. 启动连接监控
self.connection_monitor = ConnectionMonitor(self)
self.connection_monitor.start()
# 3. 启动 HTTP 回调服务
if hasattr(self, '_http_service_config'):
self.http_service = WorkstationHTTPService(
workstation_instance=self,
host=self._http_service_config["host"],
port=self._http_service_config["port"],
)
self.http_service.start()
# 4. 上传 deck 到云端
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [self.deck]}
)
# 5. 初始化资源同步器(可选)
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
```

View File

@@ -0,0 +1,233 @@
---
name: batch-insert-reagent
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
---
# 批量录入试剂 Skill
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
# 方式二:手动计算
# base64(ak:sk) → Authorization: Lab <token>
```
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
|-------------|------|
| `test` | `https://uni-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
## 请求约定
所有请求使用 `curl -s`POST 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
```
记住 `data.uuid``lab_uuid`
### 2. 录入试剂
```bash
curl -s -X POST "$BASE/api/v1/lab/reagent" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{
"lab_uuid": "<lab_uuid>",
"cas": "<CAS号>",
"name": "<试剂名称>",
"molecular_formula": "<分子式>",
"smiles": "<SMILES>",
"stock_in_quantity": <入库数量>,
"unit": "<单位字符串>",
"supplier": "<供应商>",
"production_date": "<生产日期 ISO 8601>",
"expiry_date": "<过期日期 ISO 8601>"
}'
```
返回成功时包含试剂 UUID
```json
{"code": 0, "data": {"uuid": "xxx", ...}}
```
---
## 试剂字段说明
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `lab_uuid` | string | 是 | 实验室 UUID从 API #1 获取) | `"8511c672-..."` |
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
| `production_date` | string | 否 | 生产日期ISO 8601 | `"2025-11-18T00:00:00Z"` |
| `expiry_date` | string | 否 | 过期日期ISO 8601 | `"2026-11-18T00:00:00Z"` |
### unit 单位值
| 值 | 单位 |
|------|------|
| `"mL"` | 毫升 |
| `"L"` | 升 |
| `"g"` | 克 |
| `"kg"` | 千克 |
| `"瓶"` | 瓶 |
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
---
## 批量录入策略
### 方式一:用户提供 JSON 数组
用户一次性给出多条试剂数据:
```json
[
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
]
```
Agent 自动为每条补充 `lab_uuid``production_date``expiry_date` 等字段后逐条提交。
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
### 方式二:用户逐个描述
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇Sigma 的」agent 自行补全字段:
1. 根据名称查找 CAS 号、分子式、SMILES参考下方速查表或自行推断
2. 构建完整的请求体
3. 向用户确认后提交
### 方式三:从 CSV/表格批量导入
用户提供 CSV 或表格文件路径agent 读取并解析:
```bash
# 期望的 CSV 格式(首行为表头)
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
```
### 执行与汇报
每次 API 调用后:
1. 检查返回 `code`0 = 成功)
2. 记录成功/失败数量
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
4. 如有失败,列出失败的试剂名称和错误信息
---
## 常见试剂速查表
| 名称 | CAS | 分子式 | SMILES |
|------|-----|--------|--------|
| 水 | 7732-18-3 | H2O | O |
| 乙醇 | 64-17-5 | C2H6O | CCO |
| 甲醇 | 67-56-1 | CH4O | CO |
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
| 乙腈 | 75-05-8 | C2H3N | CC#N |
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
| 盐酸 | 7647-01-0 | HCl | Cl |
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
> 此表仅供快速参考。对于不在表中的试剂agent 应根据化学知识推断或提示用户补充。
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件
- [ ] Step 5: 补全缺失字段CAS、分子式、SMILES 等)
- [ ] Step 6: 向用户确认待录入的试剂列表
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
```
---
## 完整示例
用户说:「帮我录入 3 种试剂500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
Agent 构建的请求序列:
```json
// 第 1 条
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
// 第 2 条
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
// 第 3 条
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
```

View File

@@ -0,0 +1,325 @@
---
name: batch-submit-experiment
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
---
# 批量提交实验指南
通过云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
## 前置条件(缺一不可)
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
# 方式二:手动计算
# base64(ak:sk) → Authorization: Lab <token>
```
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
|-------------|------|
| `test` | `https://uni-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
### 3. req_device_registry_upload.json设备注册表
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
按优先级搜索:
```
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
<workspace 根目录>/req_device_registry_upload.json
```
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
### 4. workflow_uuid目标工作流
用户需要提供要提交的 workflow UUID。如果用户不确定通过 API #3 列出可用 workflow 供选择。
**四项全部就绪后才可开始。**
## Session State
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `project_uuid` — 项目 UUID通过 API #2 列出项目列表,**让用户选择**
- `workflow_uuid` — 工作流 UUID用户提供或从列表选择
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名从 API #4 获取)
## 请求约定
所有请求使用 `curl -s`POST 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
>
> **PowerShell JSON 传参**PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
```
记住 `data.uuid``lab_uuid`
### 2. 列出实验室项目(让用户选择项目)
```bash
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
```
返回项目列表,展示给用户选择。列出每个项目的 `uuid``name`
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
### 3. 列出可用 workflow
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
```
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid``name`
### 4. 获取 workflow 模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
- 每个 action 节点的 `node_uuid`
- 每个节点对应的设备 ID`resource_template_name`
- 每个节点的动作名(`node_template_name`
- 每个节点的现有参数(`param`
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
### 5. 提交实验(创建 notebook
```bash
curl -s -X POST "$BASE/api/v1/lab/notebook" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体结构:
```json
{
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
}
]
}
```
> **注意**`sample_uuids` 必须是 **UUID 数组**`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
### 6. 查询 notebook 状态
提交成功后,使用返回的 notebook UUID 查询执行状态:
```bash
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
```
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
---
## Notebook 请求体详解
### node_params 结构
`node_params` 是一个数组,**每个元素代表一轮实验**
- 要跑 2 轮 → `node_params` 有 2 个元素
- 要跑 N 轮 → `node_params` 有 N 个元素
### 每轮的字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
### datas 中每个节点
| 字段 | 类型 | 说明 |
|------|------|------|
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #4 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
### sample_params 中每条
| 字段 | 类型 | 说明 |
|------|------|------|
| `container_uuid` | string | 容器 UUID |
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
---
## 从本地注册表生成 param 模板
### 自动方式 — 运行脚本
```bash
python scripts/gen_notebook_params.py \
--auth <token> \
--base <BASE_URL> \
--workflow-uuid <workflow_uuid> \
[--registry <path/to/req_device_registry_upload.json>] \
[--rounds <轮次数>] \
[--output <输出文件路径>]
```
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
脚本会:
1. 调用 workflow detail API 获取所有 action 节点
2. 读取本地注册表,为每个节点查找对应的 action schema
3. 生成 `notebook_template.json`,包含:
- 完整 `node_params` 骨架
- 每个节点的 param 字段及类型说明
- `_schema_info` 辅助信息(不提交,仅供参考)
### 手动方式
如果脚本不可用或注册表不存在:
1. 调用 API #4 获取 workflow 详情
2. 找到每个 action 节点的 `node_uuid`
3. 在本地注册表中查找对应设备的 `action_value_mappings`
```
resources[].id == <device_id>
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
```
4. 将 schema 中的 properties 作为 `param` 的字段模板
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
### 注册表结构参考
```json
{
"resources": [
{
"id": "liquid_handler.prcxi",
"class": {
"module": "unilabos.devices.xxx:ClassName",
"action_value_mappings": {
"transfer_liquid": {
"type": "LiquidHandlerTransfer",
"schema": {
"properties": {
"goal": {
"properties": {
"asp_vols": {"type": "array", "items": {"type": "number"}},
"sources": {"type": "array"}
},
"required": ["asp_vols", "sources"]
}
}
},
"goal_default": {}
}
}
}
}
]
}
```
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
- [ ] Step 5: 确认 workflow_uuid用户提供或从 GET #3 列表选择)
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
- [ ] Step 9: 引导用户填写每轮的参数sample_uuids、param、sample_params
- [ ] Step 10: 构建完整请求体(含 project_uuid→ POST /lab/notebook 提交
- [ ] Step 11: 检查返回结果,记录 notebook UUID
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
```
---
## 常见问题
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
### Q: 多轮实验的参数完全不同吗?
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
### Q: 如何获取 sample_uuids 和 container_uuid
这些 UUID 通常来自实验室的样品管理系统。向用户询问或从资源树API `GET /lab/material/download/$lab_uuid`)中查找。

View File

@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
用法:
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
选项:
--auth <token> Lab tokenbase64(ak:sk) 的结果,不含 "Lab " 前缀)
--base <url> API 基础 URL如 https://uni-lab.test.bohrium.com
--workflow-uuid <uuid> 目标 workflow 的 UUID
--registry <path> 本地注册表文件路径(默认自动搜索)
--rounds <n> 实验轮次数(默认 1
--output <path> 输出模板文件路径(默认 notebook_template.json
--dump-response 打印 workflow detail API 的原始响应(调试用)
示例:
python gen_notebook_params.py \\
--auth YTFmZDlkNGUtxxxx \\
--base https://uni-lab.test.bohrium.com \\
--workflow-uuid abc-123-def \\
--rounds 2
"""
import copy
import json
import os
import sys
from datetime import datetime
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
REGISTRY_FILENAME = "req_device_registry_upload.json"
def find_registry(explicit_path=None):
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
if explicit_path:
if os.path.isfile(explicit_path):
return explicit_path
if os.path.isdir(explicit_path):
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
if os.path.isfile(fp):
return fp
print(f"警告: 指定的注册表路径不存在: {explicit_path}")
return None
candidates = [
os.path.join("unilabos_data", REGISTRY_FILENAME),
REGISTRY_FILENAME,
]
for c in candidates:
if os.path.isfile(c):
return c
script_dir = os.path.dirname(os.path.abspath(__file__))
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
for c in candidates:
path = os.path.join(workspace_root, c)
if os.path.isfile(path):
return path
cwd = os.getcwd()
for _ in range(5):
parent = os.path.dirname(cwd)
if parent == cwd:
break
cwd = parent
for c in candidates:
path = os.path.join(cwd, c)
if os.path.isfile(path):
return path
return None
def load_registry(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def build_registry_index(registry_data):
"""构建 device_id → action_value_mappings 的索引"""
index = {}
for res in registry_data.get("resources", []):
rid = res.get("id", "")
avm = res.get("class", {}).get("action_value_mappings", {})
if rid and avm:
index[rid] = avm
return index
def flatten_goal_schema(action_data):
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
schema = action_data.get("schema", {})
goal_schema = schema.get("properties", {}).get("goal", {})
return goal_schema if goal_schema else schema
def build_param_template(goal_schema):
"""根据 goal schema 生成 param 模板,含类型标注"""
properties = goal_schema.get("properties", {})
required = set(goal_schema.get("required", []))
template = {}
for field_name, field_def in properties.items():
if field_name == "unilabos_device_id":
continue
ftype = field_def.get("type", "any")
default = field_def.get("default")
if default is not None:
template[field_name] = default
elif ftype == "string":
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
elif ftype == "number" or ftype == "integer":
template[field_name] = 0
elif ftype == "boolean":
template[field_name] = False
elif ftype == "array":
template[field_name] = []
elif ftype == "object":
template[field_name] = {}
else:
template[field_name] = f"$TODO ({ftype})"
return template
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
"""调用 workflow detail API"""
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
req = Request(url, method="GET")
req.add_header("Authorization", f"Lab {auth_token}")
try:
with urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
print(f"API 错误 {e.code}: {body}")
return None
except URLError as e:
print(f"网络错误: {e.reason}")
return None
def extract_nodes_from_response(response):
"""
从 workflow detail 响应中提取 action 节点列表。
适配多种可能的响应格式。
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
"""
data = response.get("data", response)
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
nodes_raw = None
for key in search_keys:
if key in data and isinstance(data[key], list):
nodes_raw = data[key]
break
if nodes_raw is None:
if isinstance(data, list):
nodes_raw = data
else:
for v in data.values():
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
nodes_raw = v
break
if not nodes_raw:
print("警告: 未能从响应中提取节点列表")
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
return []
result = []
for node in nodes_raw:
if not isinstance(node, dict):
continue
node_uuid = (
node.get("uuid")
or node.get("node_uuid")
or node.get("id")
or ""
)
resource_name = (
node.get("resource_template_name")
or node.get("device_id")
or node.get("resource_name")
or node.get("device_name")
or ""
)
template_name = (
node.get("node_template_name")
or node.get("action_name")
or node.get("template_name")
or node.get("action")
or node.get("name")
or ""
)
existing_param = node.get("param", {}) or {}
if node_uuid:
result.append((node_uuid, resource_name, template_name, existing_param))
return result
def generate_template(nodes, registry_index, rounds):
"""生成 notebook 提交模板"""
node_params = []
schema_info = {}
datas_template = []
for node_uuid, resource_name, template_name, existing_param in nodes:
param_template = {}
matched = False
if resource_name and template_name and resource_name in registry_index:
avm = registry_index[resource_name]
if template_name in avm:
goal_schema = flatten_goal_schema(avm[template_name])
param_template = build_param_template(goal_schema)
goal_default = avm[template_name].get("goal_default", {})
if goal_default:
for k, v in goal_default.items():
if k in param_template and v is not None:
param_template[k] = v
matched = True
schema_info[node_uuid] = {
"device_id": resource_name,
"action_name": template_name,
"action_type": avm[template_name].get("type", ""),
"schema_properties": list(goal_schema.get("properties", {}).keys()),
"required": goal_schema.get("required", []),
}
if not matched and existing_param:
param_template = existing_param
if not matched and not existing_param:
schema_info[node_uuid] = {
"device_id": resource_name,
"action_name": template_name,
"warning": "未在本地注册表中找到匹配的 action schema",
}
datas_template.append({
"node_uuid": node_uuid,
"param": param_template,
"sample_params": [
{
"container_uuid": "$TODO_CONTAINER_UUID",
"sample_value": {
"liquid_names": "$TODO_LIQUID_NAME",
"volumes": 0,
},
}
],
})
for i in range(rounds):
node_params.append({
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
"datas": copy.deepcopy(datas_template),
})
return {
"lab_uuid": "$TODO_LAB_UUID",
"project_uuid": "$TODO_PROJECT_UUID",
"workflow_uuid": "$TODO_WORKFLOW_UUID",
"name": "$TODO_EXPERIMENT_NAME",
"node_params": node_params,
"_schema_info仅参考提交时删除": schema_info,
}
def parse_args(argv):
"""简单的参数解析"""
opts = {
"auth": None,
"base": None,
"workflow_uuid": None,
"registry": None,
"rounds": 1,
"output": "notebook_template.json",
"dump_response": False,
}
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--auth" and i + 1 < len(argv):
opts["auth"] = argv[i + 1]
i += 2
elif arg == "--base" and i + 1 < len(argv):
opts["base"] = argv[i + 1].rstrip("/")
i += 2
elif arg == "--workflow-uuid" and i + 1 < len(argv):
opts["workflow_uuid"] = argv[i + 1]
i += 2
elif arg == "--registry" and i + 1 < len(argv):
opts["registry"] = argv[i + 1]
i += 2
elif arg == "--rounds" and i + 1 < len(argv):
opts["rounds"] = int(argv[i + 1])
i += 2
elif arg == "--output" and i + 1 < len(argv):
opts["output"] = argv[i + 1]
i += 2
elif arg == "--dump-response":
opts["dump_response"] = True
i += 1
else:
print(f"未知参数: {arg}")
i += 1
return opts
def main():
opts = parse_args(sys.argv[1:])
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
print("用法:")
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
print()
print("必需参数:")
print(" --auth <token> Lab tokenbase64(ak:sk)")
print(" --base <url> API 基础 URL")
print(" --workflow-uuid <uuid> 目标 workflow UUID")
print()
print("可选参数:")
print(" --registry <path> 注册表文件路径(默认自动搜索)")
print(" --rounds <n> 实验轮次数(默认 1")
print(" --output <path> 输出文件路径(默认 notebook_template.json")
print(" --dump-response 打印 API 原始响应")
sys.exit(1)
# 1. 查找并加载本地注册表
registry_path = find_registry(opts["registry"])
registry_index = {}
if registry_path:
mtime = os.path.getmtime(registry_path)
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
print(f"注册表: {registry_path} (生成时间: {gen_time})")
registry_data = load_registry(registry_path)
registry_index = build_registry_index(registry_data)
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
else:
print("警告: 未找到本地注册表,将跳过 param 模板生成")
print(" 提交时需要手动填写各节点的 param 字段")
# 2. 获取 workflow 详情
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
if not response:
print("错误: 无法获取 workflow 详情")
sys.exit(1)
if opts["dump_response"]:
print("\n=== API 原始响应 ===")
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
print("=== 响应结束(截断至 5000 字符) ===\n")
# 3. 提取节点
nodes = extract_nodes_from_response(response)
if not nodes:
print("错误: 未能从 workflow 中提取任何 action 节点")
print("请使用 --dump-response 查看原始响应结构")
sys.exit(1)
print(f"\n找到 {len(nodes)} 个 action 节点:")
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
print(" " + "-" * 110)
for node_uuid, resource_name, template_name, _ in nodes:
matched = "" if (resource_name in registry_index and
template_name in registry_index.get(resource_name, {})) else ""
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
# 4. 生成模板
template = generate_template(nodes, registry_index, opts["rounds"])
template["workflow_uuid"] = opts["workflow_uuid"]
output_path = opts["output"]
with open(output_path, "w", encoding="utf-8") as f:
json.dump(template, f, indent=2, ensure_ascii=False)
print(f"\n模板已写入: {output_path}")
print(f" 轮次数: {opts['rounds']}")
print(f" 节点数/轮: {len(nodes)}")
print()
print("下一步:")
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
print(" 2. 删除 _schema_info 字段(仅供参考)")
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
if __name__ == "__main__":
main()

View File

@@ -158,12 +158,13 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- `unilabos_devices`**DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device
- `unilabos_nodes`**NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
- `unilabos_class`**ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
- `unilabos_formulation`**FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`well_name 为目标物料的 name
- array 类型字段 → `[{id, name, uuid}, ...]`
- 特殊:`create_resource``res_id`ResourceSlot可填不存在的路径
### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板10 个 endpoint,修改:
直接复用 `unilab-device-api` 的 API 模板,修改:
- 设备名称
- Action 数量
- 目录列表
@@ -181,21 +182,37 @@ API 模板结构:
## 前置条件(缺一不可)
- ak/sk → AUTH, --addr → BASE URL
## Session State
- lab_uuid通过 API #1 自动匹配,不要问用户), device_name
## 请求约定
- Windows 平台必须用 curl.exe非 PowerShell 的 curl 别名)
## API Endpoints (10 个)
# 注意:
# - #1 获取 lab 列表 + 自动匹配 lab_uuid遍历 is_admin 的 lab
# 调用 /lab/info/{uuid} 比对 access_key == ak
# - #2 创建工作流用 POST /lab/workflow
# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid}
## Session State
- lab_uuid通过 GET /edge/lab/info 直接获取,不要问用户), device_name
## API Endpoints
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
# - #3 创建节点 POST /edge/workflow/node
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
# - #4 删除节点 DELETE /lab/workflow/nodes
# - #5 更新节点参数 PATCH /lab/workflow/node
# - #6 查询节点 handles POST /lab/workflow/node-handles
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
# - #7 批量创建边 POST /lab/workflow/edges
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
# - #9 运行设备单动作 POST /lab/mcp/run/action
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
# 返回 workflow 完整结构data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
## Placeholder Slot 填写规则
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
- unilabos_class → ClassSlot → "class_name" 字符串
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
- 特例create_resource 的 res_id 允许填不存在的路径
- 列出本设备所有 Slot 字段、类型及含义
@@ -206,8 +223,8 @@ API 模板结构:
### Step 5 — 验证
检查文件完整性:
- [ ] `SKILL.md` 包含 10 个 API endpoint
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `action-index.md` 列出所有 action 并有描述
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
@@ -249,11 +266,11 @@ API 模板结构:
```
> **注意**`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段PATCH 更新时直接修改 `param` 即可
## Placeholder Slot 类型体系
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|---------------|-----------|---------|---------|
@@ -261,6 +278,7 @@ API 模板结构:
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点type=device路径字符串 |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
### ResourceSlot`unilabos_resources`
@@ -307,7 +325,41 @@ API 模板结构:
"container"
```
### 通过 API #10 获取资源树
### FormulationSlot`unilabos_formulation`
描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**
```json
[
{
"sample_uuid": "",
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
"liquids": [
{ "name": "LiPF6", "volume": 0.6 },
{ "name": "DMC", "volume": 1.2 }
]
}
]
```
#### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `sample_uuid` | string | 样品 UUID无样品时传空字符串 `""` |
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
| `liquids` | array | 要加入的液体列表 |
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL |
#### 填写规则
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
- 每个数组元素代表一个目标容器的配方
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
- 与 ResourceSlot 的区别ResourceSlot 填 `{id, name, uuid}` 指向物料本身FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
### 通过 API #12 获取资源树
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"

View File

@@ -0,0 +1,275 @@
---
name: submit-agent-result
description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
---
# 提交历史实验记录指南
通过云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
## 前置条件(缺一不可)
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token
```bash
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
```
输出即为 token 值,拼接为 `Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
|-------------|------|
| `test` | `https://uni-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
### 3. notebook_uuid**必须询问用户**
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`
如果用户不记得,可提示:
- 查看之前的对话记录中创建 notebook 时返回的 UUID
- 或通过平台页面查找对应的 notebook
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
### 4. 实验结果数据
用户需要提供实验结果数据,支持以下方式:
| 方式 | 说明 |
|------|------|
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
**四项全部就绪后才可开始。**
## Session State
在整个对话过程中agent 需要记住以下状态:
- `lab_uuid` — 实验室 UUID通过 API #1 自动获取,**不需要问用户**
- `notebook_uuid` — 目标 notebook UUID**必须询问用户**
## 请求约定
所有请求使用 `curl -s`PUT 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
>
> **PowerShell JSON 传参**PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
```
记住 `data.uuid``lab_uuid`
### 2. 提交实验结果agent_result
```bash
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体结构:
```json
{
"notebook_uuid": "<notebook_uuid>",
"agent_result": {
"<key1>": "<value1>",
"<key2>": 123,
"<nested_key>": {"a": 1, "b": 2},
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
}
}
```
> **注意**HTTP 方法是 **PUT**(不是 POST
#### 必要字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID从批量提交实验时获取 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
#### agent_result 内容格式
`agent_result` 接受**任意 JSON 对象**,常见格式:
**简单键值对**
```json
{
"avg_rtt_ms": 12.5,
"status": "success",
"test_count": 5
}
```
**包含嵌套结构**
```json
{
"summary": {"total": 100, "passed": 98, "failed": 2},
"measurements": [
{"sample_id": "S001", "value": 3.14, "unit": "mg/mL"},
{"sample_id": "S002", "value": 2.71, "unit": "mg/mL"}
]
}
```
**从 CSV 文件导入**(脚本自动转换):
```json
{
"experiment_data": [
{"温度": 25, "压力": 101.3, "产率": 0.85},
{"温度": 30, "压力": 101.3, "产率": 0.91}
]
}
```
---
## 整合脚本
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
### 用法
```bash
python scripts/prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
```
| 参数 | 必选 | 说明 |
|------|------|------|
| `--notebook-uuid` | 是 | 目标 notebook UUID |
| `--files` | 是 | 输入文件路径支持多个JSON / CSV |
| `--auth` | 提交时必选 | Lab tokenbase64(ak:sk) |
| `--base` | 提交时必选 | API base URL |
| `--submit` | 否 | 加上此标志则直接提交到云端 |
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json` |
### 文件合并规则
| 文件类型 | 合并方式 |
|----------|----------|
| `.json`dict | 字段直接合并到 `agent_result` 顶层 |
| `.json`list/other | 以文件名为 key 放入 `agent_result` |
| `.csv` | 以文件名(不含扩展名)为 key值为行对象数组 |
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
### 示例
```bash
# 仅生成请求体文件(不提交)
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json measurements.csv
# 生成并直接提交
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json \
--auth YTFmZDlkNGUt... \
--base https://uni-lab.test.bohrium.com \
--submit
```
---
## 手动构建方式
如果不使用脚本,也可手动构建请求体:
1. 将实验结果数据组装为 JSON 对象
2. 写入临时文件:
```json
{
"notebook_uuid": "<uuid>",
"agent_result": { ... }
}
```
3. 用 curl 提交:
```bash
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '@tmp_body.json'
```
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: **询问用户** notebook_uuid必须不可跳过
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
- [ ] Step 8: 检查返回结果,确认提交成功
```
---
## 常见问题
### Q: notebook_uuid 从哪里获取?
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
### Q: agent_result 有固定的 schema 吗?
没有严格 schema接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
### Q: 可以多次提交同一个 notebook 的结果吗?
可以,后续提交会覆盖之前的 agent_result。
### Q: 认证方式是 Lab 还是 Api
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。

View File

@@ -0,0 +1,133 @@
"""
读取实验结果文件JSON / CSV整合为 agent_result 请求体并可选提交。
用法:
python prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <Lab token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
支持的输入文件格式:
- .json → 直接作为 dict 合并
- .csv → 转为 {"filename": [row_dict, ...]} 格式
"""
import argparse
import base64
import csv
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List
def read_json_file(filepath: str) -> Dict[str, Any]:
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
rows = []
with open(filepath, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
converted = {}
for k, v in row.items():
try:
converted[k] = int(v)
except (ValueError, TypeError):
try:
converted[k] = float(v)
except (ValueError, TypeError):
converted[k] = v
rows.append(converted)
return rows
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
"""将多个文件合并为一个 agent_result dict"""
merged: Dict[str, Any] = {}
for fp in filepaths:
path = Path(fp)
ext = path.suffix.lower()
key = path.stem
if ext == ".json":
data = read_json_file(fp)
if isinstance(data, dict):
merged.update(data)
else:
merged[key] = data
elif ext == ".csv":
merged[key] = read_csv_file(fp)
else:
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
return merged
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
return {
"notebook_uuid": notebook_uuid,
"agent_result": agent_result,
}
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
try:
import requests
except ImportError:
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
sys.exit(1)
url = f"{base}/api/v1/lab/notebook/agent-result"
headers = {
"Content-Type": "application/json",
"Authorization": f"Lab {auth}",
}
resp = requests.put(url, json=body, headers=headers, timeout=30)
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
def main():
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径JSON / CSV")
parser.add_argument("--auth", help="Lab tokenbase64(ak:sk)")
parser.add_argument("--base", help="API base URL")
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
args = parser.parse_args()
for fp in args.files:
if not os.path.exists(fp):
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
sys.exit(1)
agent_result = merge_files(args.files)
body = build_request_body(args.notebook_uuid, agent_result)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(body, f, ensure_ascii=False, indent=2)
print(f"[完成] 请求体已保存: {args.output}")
print(f" notebook_uuid: {args.notebook_uuid}")
print(f" agent_result 字段数: {len(agent_result)}")
print(f" 合并文件数: {len(args.files)}")
if args.submit:
if not args.auth or not args.base:
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
sys.exit(1)
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
result = submit(args.base, args.auth, body)
print(f" HTTP {result['status_code']}")
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
if __name__ == "__main__":
main()

5
.gitignore vendored
View File

@@ -251,4 +251,7 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
*.bz2
test_config.py
# Local config files with secrets
yibin_coin_cell_only_config.json
yibin_electrolyte_config.json
yibin_electrolyte_only_config.json

View File

@@ -0,0 +1,72 @@
# CSV 导出功能变更概要
## 修改的文件
### 1. [bioyond_cell_workstation.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)
#### 新增导入
- `import csv``import os`L14-15
#### 新增方法
| 方法 | 功能 |
|------|------|
| `_extract_prep_bottle_from_report` | 从 order_finish 报文提取**配液瓶**信息每订单最多1个 |
| `_extract_vial_bottles_from_report` | 从 order_finish 报文提取**分液瓶**信息(每订单可多个,返回数组) |
| `_export_order_csv` | 汇总所有信息写入 CSV 文件 |
#### 配液瓶筛选逻辑 (`_extract_prep_bottle_from_report`)
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
- `locationId``3a19deae-2c7a-` 开头(手动传递窗)
- LIMS API 二次确认:`typeName` 含"配液瓶(小)"或"配液瓶(大)"
#### 分液瓶筛选逻辑 (`_extract_vial_bottles_from_report`)
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
- `locationId``3a19debc-84b5-``3a19debe-5200` 开头(自动堆栈-左/右)
- LIMS API 二次确认:`typeName` 为"5ml分液瓶"或"20ml分液瓶"
- **返回数组**,支持 1×5ml + n×20ml 的组合
#### 修改的方法
| 方法 | 变更 |
|------|------|
| `_submit_and_wait_orders` | 新增配液瓶+分液瓶提取步骤,将 `prep_bottles``vial_bottles` 存入 `final_result` |
| `create_orders` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
| `create_orders_formulation` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
#### CSV 输出格式
```
orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间
```
- 单个分液瓶时直接写值;多个分液瓶时类型和二维码用 JSON 数组表示
- CSV 编码使用 `utf-8-sig`(兼容 Excel 打开)
- `csv_export_path` 默认为空字符串,不传则不导出(向后兼容)
---
### 2. [bioyond_cell.yaml](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/registry/devices/bioyond_cell.yaml)
为两个 action 注册了 `csv_export_path` 参数:
- `auto-create_orders`: `goal_default` + `schema.properties.goal.properties` 中添加 `csv_export_path`
- `auto-create_orders_formulation`: 同上
---
### 3. [coin_cell_assembly.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py) 的 CSV 改动与全流程追溯
`bioyond_cell_workstation.py``_submit_and_wait_orders` 最后阶段,提取 `prep_bottles`(配液瓶)和 `vial_bottles`(分液瓶)的条码并随 `mass_ratios` 数组一起下发给各下游工站(例如扣电组装站),实现跨站的全流程配方追溯。
并在扣电站生成的 `date_xxx.csv` 中,**替换并新增**了以下列:
- 移除了原有的 `formulation_order_code` 与合并的 `formulation_ratio` 列。
- 新增 `orderName` 导出
- 新增 `prep_bottle_barcode`(奔曜传递的配液瓶二维码)
- 新增 `vial_bottle_barcodes`(奔曜传递的分液瓶二维码,多瓶时存 JSON 数组)
- 新增 `target_mass_ratio` 理论目标质量比
- 新增 `real_mass_ratio` 实际称量真实质量比
*注意:这与操作人员在手套箱内扫码传入扣电站的 `electrolyte_code` 是单独记录的,方便做数据核对。*
## 向后兼容性
- `csv_export_path` 默认值为 `""`(空字符串),现有调用不受影响
- 新增的 `prep_bottles``vial_bottles` 字段为 `final_result``mass_ratios` 内部的新增附属字段,不破坏现有数据结构。

168
CHANGES_2026_03_24.md Normal file
View File

@@ -0,0 +1,168 @@
# 变更说明 2026-03-24
## 问题背景
`BioyondElectrolyteDeck`(原 `BIOYOND_YB_Deck`)迁移后,前端物料未能正常上传/同步。
---
## 修复内容
### 1. `unilabos/resources/bioyond/decks.py`
- 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑,与旧版 `BIOYOND_YB_Deck` 保持一致
- 工厂函数 `bioyond_electrolyte_deck` 保留显式调用 `deck.setup()`,避免重复初始化
```python
# 修复前(缺少 setup 参数,无法通过 setup=True 触发初始化)
def __init__(self, name, size_x, size_y, size_z, category):
super().__init__(...)
# 修复后
def __init__(self, name, size_x, size_y, size_z, category, setup: bool = False):
super().__init__(...)
if setup:
self.setup()
```
---
### 2. `unilabos/resources/graphio.py`
- 修复 `resource_bioyond_to_plr` 中两处 `bottle.tracker.liquids` 直接赋值导致的崩溃
- `ResourceHolder`(如枪头盒的 TipSpot 槽位)没有 `tracker` 属性,直接访问会抛出 `AttributeError`,阻断整个 Bioyond 同步流程
```python
# 修复前
bottle.tracker.liquids = [...]
# 修复后
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
bottle.tracker.liquids = [...]
```
---
### 3. `unilabos/app/main.py`
- 保留 `file_path is not None` 条件不变(已还原),并补充注释说明原因
- 该逻辑只在**本地文件模式**下有意义:本地 graph 文件只含设备结构远端有已保存物料merge 才能将两者合并
- 远端模式(`file_path=None`)下,`resource_tree_set``request_startup_json` 来自同一份数据merge 为空操作,条件是否加 `file_path is not None` 对结果没有影响
---
### 4. `unilabos/devices/workstation/bioyond_studio/station.py` ⭐ 核心修复
- 当 deck 通过反序列化创建时,不会自动调用 `setup()`,导致 `deck.children` 为空,`warehouses` 始终是 `{}`
- 增加兜底逻辑:仓库扫描后仍为空,则主动调用 `deck.setup()` 初始化仓库
- 这是导致所有物料放置失败(`warehouse '...' 在deck中不存在。可用warehouses: []`)的根本原因
```python
# 新增兜底
if not self.deck.warehouses and hasattr(self.deck, "setup") and callable(self.deck.setup):
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
self.deck.setup()
```
---
---
## 补充修复 2026-03-25依华扣电组装工站子物料未上传
### 问题
`CoinCellAssemblyWorkstation.post_init` 直接上传空 deck未调用 `deck.setup()`,导致:
- 前端子物料(成品弹夹、料盘、瓶架等)不显示
- 运行时 `self.deck.get_resource("成品弹夹")` 抛出 `ResourceNotFoundError`
### 修复文件
**`unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`**
- `YihuaCoinCellDeck.__init__` 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑
**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`**
- `post_init` 中增加与 Bioyond 工站相同的兜底逻辑deck 无子节点时调用 `deck.setup()` 初始化
```python
# post_init 中新增
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
self.deck.setup()
```
### 联动 Bug`MaterialPlate.create_with_holes` 构造顺序错误
**现象**`deck.setup()` 被调用后,启动时抛出:
```
设备后初始化失败: Must specify either `ordered_items` or `ordering`.
```
**根因**`create_with_holes` 原来的逻辑是先构造空的 `MaterialPlate` 实例,再 assign 洞位:
```python
# 旧错误cls(...) 时 ordered_items=None → ItemizedResource.__init__ 立即报错
plate = cls(name=name, ...) # ← 这里就崩了
holes = create_ordered_items_2d(...) # ← 根本没走到这里
for hole_name, hole in holes.items():
plate.assign_child_resource(...)
```
pylabrobot 的 `ItemizedResource.__init__` 强制要求 `ordered_items``ordering` 必须有一个不为 `None`,空构造直接失败。
**修复**:先建洞位,再作为 `ordered_items` 传给构造函数:
```python
# 新(正确):先建洞位,再一次性传入构造函数
holes = create_ordered_items_2d(klass=MaterialHole, num_items_x=4, ...)
return cls(name=name, ..., ordered_items=holes)
```
> 此 bug 此前未被触发,是因为 `deck.setup()` 从未被调用到——正是上面 `post_init` 兜底修复引出的联动问题。
---
## 补充修复 2026-03-253→2→1 转运资源同步失败
### 问题
配液工站Bioyond完成分液后调用 `transfer_3_to_2_to_1_auto` 将分液瓶板转运到扣电工站BatteryStation。物理 LIMS 转运成功,但数字孪生资源树同步始终失败:
```
[资源同步] ❌ 失败: 目标设备 'BatteryStation' 中未找到资源 'bottle_rack_6x2'
```
### 根因
`_get_resource_from_device` 方法负责跨设备查找资源对象,有两个问题:
1. **原始路径完全失效**:尝试 `from unilabos.app.ros2_app import get_device_plr_resource_by_name`,但该模块不存在,`ImportError``except Exception: pass` 静默吞掉
2. **降级路径搜错地方**:遍历 `self._plr_resources`Bioyond 自己的资源),不可能找到 BatteryStation 的 `bottle_rack_6x2`
### 修复文件
**`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`**
改用全局设备注册表 `registered_devices` 跨设备访问目标 deck
```python
# 修复前(失效)
from unilabos.app.ros2_app import get_device_plr_resource_by_name # 模块不存在
return get_device_plr_resource_by_name(device_id, resource_name)
# 修复后
from unilabos.ros.nodes.base_device_node import registered_devices
device_info = registered_devices.get(device_id)
if device_info is not None:
driver = device_info.get("driver_instance") # TypedDict 是 dict必须用 .get()
if driver is not None:
deck = getattr(driver, "deck", None)
if deck is not None:
res = deck.get_resource(resource_name)
```
关键细节:`DeviceInfoType``TypedDict`(即普通 `dict`),必须用 `device_info.get("driver_instance")` 而非 `getattr(device_info, "driver_instance", None)`——后者对字典永远返回 `None`
---
## 根本原因分析
旧版以**本地文件模式**启动(有 `graph` 文件deck 在启动前已通过 `merge_remote_resources` 获得仓库子节点,反序列化时能正确恢复 warehouses。
新版以**远端模式**启动(`file_path=None`deck 反序列化时没有仓库子节点,`station.py` 扫描为空,所有物料的 warehouse 匹配失败Bioyond 同步的 16 个资源全部无法放置到对应仓库位,前端不显示。

View File

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

View File

@@ -264,6 +264,12 @@ def parse_args():
default=False,
help="Test mode: all actions simulate execution and return mock results without running real hardware",
)
parser.add_argument(
"--external_devices_only",
action="store_true",
default=False,
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
)
parser.add_argument(
"--extra_resource",
action="store_true",
@@ -342,11 +348,18 @@ def main():
check_mode = args_dict.get("check_mode", False)
if not skip_env_check:
from unilabos.utils.environment_check import check_environment
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
if not check_environment(auto_install=True):
print_status("环境检查失败,程序退出", "error")
os._exit(1)
# 第一次设备包依赖检查build_registry 之前,确保 import map 可用
devices_dirs_for_req = args_dict.get("devices", None)
if devices_dirs_for_req:
if not check_device_package_requirements(devices_dirs_for_req):
print_status("设备包依赖检查失败,程序退出", "error")
os._exit(1)
else:
print_status("跳过环境依赖检查", "warning")
@@ -477,19 +490,7 @@ def main():
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
BasicConfig.check_mode = check_mode
from unilabos.resources.graphio import (
read_node_link_json,
read_graphml,
dict_from_graph,
)
from unilabos.app.communication import get_communication_client
from unilabos.registry.registry import build_registry
from unilabos.app.backend import start_backend
from unilabos.app.web import http_client
from unilabos.app.web import start_server
from unilabos.app.register import register_devices_and_resources
from unilabos.resources.graphio import modify_to_backend_format
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
# 显示启动横幅
print_unilab_banner(args_dict)
@@ -498,12 +499,14 @@ def main():
# check_mode 和 upload_registry 都会执行实际 import 验证
devices_dirs = args_dict.get("devices", None)
complete_registry = args_dict.get("complete_registry", False) or check_mode
external_only = args_dict.get("external_devices_only", False)
lab_registry = build_registry(
registry_paths=args_dict["registry_path"],
devices_dirs=devices_dirs,
upload_registry=BasicConfig.upload_registry,
check_mode=check_mode,
complete_registry=complete_registry,
external_only=external_only,
)
# Check mode: 注册表验证完成后直接退出
@@ -513,6 +516,20 @@ def main():
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
os._exit(0)
# 以下导入依赖 ROS2 环境check_mode 已退出不需要
from unilabos.resources.graphio import (
read_node_link_json,
read_graphml,
dict_from_graph,
modify_to_backend_format,
)
from unilabos.app.communication import get_communication_client
from unilabos.app.backend import start_backend
from unilabos.app.web import http_client
from unilabos.app.web import start_server
from unilabos.app.register import register_devices_and_resources
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
if BasicConfig.upload_registry:
if BasicConfig.ak and BasicConfig.sk:
@@ -604,12 +621,18 @@ def main():
continue
# 如果从远端获取了物料信息,则与本地物料进行同步
# 仅在本地文件模式下有意义:本地文件只含设备结构,远端有已保存的物料,需要 merge
# 远端模式下 resource_tree_set 与 request_startup_json 来自同一份数据merge 为空操作
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
print_status("开始同步远端物料到本地...", "info")
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
resource_tree_set.merge_remote_resources(remote_tree_set)
print_status("远端物料同步完成", "info")
# 第二次设备包依赖检查云端物料同步后community 包可能引入新的 requirements
# TODO: 当 community device package 功能上线后,在这里调用
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
# 使用 ResourceTreeSet 代替 list
args_dict["resources_config"] = resource_tree_set
args_dict["devices_config"] = resource_tree_set

View File

@@ -80,19 +80,20 @@ class HTTPClient:
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
nodes_info = [x for xs in resources.dump() for x in xs]
if not self.initialized or first_add:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
else:
response = requests.put(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=10,
)
@@ -111,6 +112,7 @@ class HTTPClient:
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else:
logger.error(f"添加物料失败: {response.text}")
logger.trace(f"添加物料失败: {nodes_info}")
for u, n in old_uuids.items():
if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u]

View File

@@ -754,6 +754,32 @@ class MessageProcessor:
req = JobAddReq(**data)
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
# 服务端对always_free动作可能跳过query_action_state直接发job_start
# 此时job尚未注册需要自动补注册
existing_job = self.device_manager.get_job_info(req.job_id)
if not existing_job:
action_name = req.action
device_action_key = f"/devices/{req.device_id}/{action_name}"
action_always_free = self._check_action_always_free(req.device_id, action_name)
if action_always_free:
job_info = JobInfo(
job_id=req.job_id,
task_id=req.task_id,
device_id=req.device_id,
action_name=action_name,
device_action_key=device_action_key,
status=JobStatus.QUEUE,
start_time=time.time(),
always_free=True,
)
self.device_manager.add_queue_request(job_info)
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
else:
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
return
success = self.device_manager.start_job(req.job_id)
if not success:
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
@@ -1087,7 +1113,7 @@ class MessageProcessor:
"task_id": task_id,
"job_id": job_id,
"free": free,
"need_more": need_more,
"need_more": need_more + 1,
},
}
@@ -1227,7 +1253,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10,
"need_more": 10 + 1,
},
}
self.message_processor.send_message(message)
@@ -1260,7 +1286,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10,
"need_more": 10 + 1,
},
}
success = self.message_processor.send_message(message)
@@ -1343,6 +1369,10 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
self._job_running_last_sent: Dict[str, tuple] = {}
self._job_running_debounce_interval: float = 10.0 # 秒
# 设置相互引用
self.message_processor.set_queue_processor(self.queue_processor)
self.message_processor.set_websocket_client(self)
@@ -1442,22 +1472,32 @@ class WebSocketClient(BaseCommunicationClient):
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
return
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
# 拦截最终结果状态,与原版本逻辑一致
if status in ["success", "failed"]:
self._job_running_last_sent.pop(item.job_id, None)
host_node = HostNode.get_instance(0)
if host_node:
# 从HostNode的device_action_status中移除job_id
try:
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
except (KeyError, AttributeError):
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
# 通知队列处理器job完成包括timeout的job
self.queue_processor.handle_job_completed(item.job_id, status)
# 发送job状态消息
# running状态按job_id做debounce内容变化时仍然上报
if status == "running":
now = time.time()
cached = self._job_running_last_sent.get(item.job_id)
if cached is not None:
last_ts, last_data = cached
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
return
self._job_running_last_sent[item.job_id] = (now, feedback_data)
message = {
"action": "job_status",
"data": {
@@ -1473,7 +1513,6 @@ class WebSocketClient(BaseCommunicationClient):
}
self.message_processor.send_message(message)
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
def send_ping(self, ping_id: str, timestamp: float) -> None:

View File

@@ -132,7 +132,10 @@ class ResourceVisualization:
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
if "rotation" in node["config"]:
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
if "pose" in node:
new_dev.set("x",str(float(node["pose"]["position"]["x"])/1000))
new_dev.set("y",str(float(node["pose"]["position"]["y"])/1000))
@@ -140,10 +143,6 @@ class ResourceVisualization:
new_dev.set("rx",str(float(node["pose"]["rotation"]["x"])))
new_dev.set("ry",str(float(node["pose"]["rotation"]["y"])))
new_dev.set("r",str(float(node["pose"]["rotation"]["z"])))
if "rotation" in node["config"]:
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
if "device_config" in node["config"]:
for key, value in node["config"]["device_config"].items():
new_dev.set(key, str(value))

View File

@@ -1,31 +1,5 @@
arm_slider_arm_controller:
ros__parameters:
command_interfaces:
- position
joints:
- arm_slider_arm_base_joint
- arm_slider_arm_link_1_joint
- arm_slider_arm_link_2_joint
- arm_slider_arm_link_3_joint
- arm_slider_gripper_base_joint
state_interfaces:
- position
- velocity
arm_slider_gripper_controller:
ros__parameters:
command_interfaces:
- position
joints:
- arm_slider_gripper_right_joint
state_interfaces:
- position
- velocity
controller_manager:
ros__parameters:
arm_slider_arm_controller:
type: joint_trajectory_controller/JointTrajectoryController
arm_slider_gripper_controller:
type: joint_trajectory_controller/JointTrajectoryController
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
update_rate: 100

View File

@@ -14,7 +14,7 @@
"config": {
"ip": "127.0.0.1",
"port": 502,
"machine_id": 1,
"machine_ids": [1, 2, 3, 4, 5, 6, 86],
"devtype": "27",
"timeout": 20,
"size_x": 500.0,
@@ -32,4 +32,4 @@
}
],
"links": []
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -305,11 +305,12 @@ class NewareBatteryTestSystem:
ascii_lowercase = 'abcdefghijklmnopqrstuvwxyz'
ascii_uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
LETTERS = ascii_uppercase + ascii_lowercase
DEFAULT_MACHINE_IDS = [1, 2, 3, 4, 5, 6, 86]
def __init__(self,
ip: str = None,
port: int = None,
machine_id: int = 1,
machine_ids: Optional[List[int]] = None,
devtype: str = None,
timeout: int = None,
@@ -326,16 +327,18 @@ class NewareBatteryTestSystem:
Args:
ip: TCP服务器IP地址
port: TCP端口
machine_ids: 设备ID列表
devtype: 设备类型标识
timeout: 通信超时时间(秒)
machine_id: 机器ID
size_x, size_y, size_z: 设备物理尺寸
oss_upload_enabled: 是否启用OSS上传功能默认False
oss_prefix: OSS对象路径前缀默认"neware_backup"
"""
self.ip = ip or self.BTS_IP
self.port = port or self.BTS_PORT
self.machine_id = machine_id
self.machine_ids = machine_ids
self.display_device_ids = self._resolve_display_device_ids()
self.primary_device_id = self.display_device_ids[0]
self.devtype = devtype or self.DEVTYPE
self.timeout = timeout or self.TIMEOUT
@@ -352,6 +355,12 @@ class NewareBatteryTestSystem:
self._cached_status = {}
self._last_backup_dir = None # 记录最近一次的 backup_dir供上传使用
self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用由框架设置
self._channels = self._build_channel_map()
def _resolve_display_device_ids(self) -> List[int]:
if self.machine_ids:
return [int(devid) for devid in self.machine_ids]
return self.DEFAULT_MACHINE_IDS.copy()
def post_init(self, ros_node):
@@ -376,27 +385,72 @@ class NewareBatteryTestSystem:
ros_node.lab_logger().error(f"新威电池测试系统初始化失败: {e}")
# 不抛出异常,允许节点继续运行,后续可以重试连接
def _plate_name(self, devid: int, plate_num: int) -> str:
return f"{devid}_P{plate_num}"
def _plate_resource_key(self, devid: int, plate_num: int, row_idx: int, col_idx: int) -> str:
return f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}"
def _get_plate_resource(self, devid: int, plate_num: int, row_idx: int, col_idx: int):
possible_names = [
f"{self._plate_name(devid, plate_num)}_batterytestposition_{col_idx}_{row_idx}",
f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}",
f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx].lower()}{col_idx + 1}",
f"P{plate_num}_batterytestposition_{col_idx}_{row_idx}",
f"P{plate_num}_{self.LETTERS[row_idx]}{col_idx + 1}",
f"P{plate_num}_{self.LETTERS[row_idx].lower()}{col_idx + 1}",
]
for name in possible_names:
if name in self.station_resources:
return self.station_resources[name], name, possible_names
return None, None, possible_names
def _setup_material_management(self):
"""设置物料管理系统"""
# 第1盘5行8列网格 (A1-E8) - 5行对应subdevid 1-58列对应chlid 1-8
# 先给物料设置一个最大的Deck并设置其在空间中的位置
deck_main = Deck("ADeckName", 2000, 1800, 100, origin=Coordinate(2000,2000,0))
plate1_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d(
BatteryTestPosition,
num_items_x=8, # 8列对应chlid 1-8
num_items_y=5, # 5行对应subdevid 1-5即A-E
dx=10,
dy=10,
dz=0,
item_dx=65,
item_dy=65
deck_main = Deck(
name="ADeckName",
size_x=2200,
size_y=2800,
size_z=100,
origin=Coordinate(2000, 2000, 0)
)
plate1 = Plate("P1", 400, 300, 50, ordered_items=plate1_resources)
deck_main.assign_child_resource(plate1, location=Coordinate(0, 0, 0))
# 只有在真实ROS环境下才调用update_resource
self.station_resources = {}
self.station_resources_by_plate = {}
for row_idx, devid in enumerate(self.display_device_ids):
for plate_num in (1, 2):
plate_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d(
BatteryTestPosition,
num_items_x=8,
num_items_y=5,
dx=10,
dy=10,
dz=0,
item_dx=65,
item_dy=65
)
plate_name = self._plate_name(devid, plate_num)
plate = Plate(
name=plate_name,
size_x=400,
size_y=300,
size_z=50,
ordered_items=plate_resources
)
location_x = 0 if plate_num == 1 else 450
location_y = row_idx * 350
deck_main.assign_child_resource(plate, location=Coordinate(location_x, location_y, 0))
plate_key = (devid, plate_num)
self.station_resources_by_plate[plate_key] = {}
for name, resource in plate_resources.items():
new_name = f"{plate_name}_{name}"
self.station_resources_by_plate[plate_key][new_name] = resource
self.station_resources[new_name] = resource
self.station_resources_plate1 = self.station_resources_by_plate.get((self.primary_device_id, 1), {})
self.station_resources_plate2 = self.station_resources_by_plate.get((self.primary_device_id, 2), {})
if hasattr(self._ros_node, 'update_resource') and callable(getattr(self._ros_node, 'update_resource')):
try:
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
@@ -405,40 +459,6 @@ class NewareBatteryTestSystem:
except Exception as e:
if hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().warning(f"更新资源失败: {e}")
# 在非ROS环境下忽略此错误
# 为第1盘资源添加P1_前缀
self.station_resources_plate1 = {}
for name, resource in plate1_resources.items():
new_name = f"P1_{name}"
self.station_resources_plate1[new_name] = resource
# 第2盘5行8列网格 (A1-E8)在Z轴上偏移 - 5行对应subdevid 6-108列对应chlid 1-8
plate2_resources = create_ordered_items_2d(
BatteryTestPosition,
num_items_x=8, # 8列对应chlid 1-8
num_items_y=5, # 5行对应subdevid 6-10即A-E
dx=10,
dy=10,
dz=0,
item_dx=65,
item_dy=65
)
plate2 = Plate("P2", 400, 300, 50, ordered_items=plate2_resources)
deck_main.assign_child_resource(plate2, location=Coordinate(0, 350, 0))
# 为第2盘资源添加P2_前缀
self.station_resources_plate2 = {}
for name, resource in plate2_resources.items():
new_name = f"P2_{name}"
self.station_resources_plate2[new_name] = resource
# 合并两盘资源为统一的station_resources
self.station_resources = {}
self.station_resources.update(self.station_resources_plate1)
self.station_resources.update(self.station_resources_plate2)
# ========================
# 核心属性Uni-Lab标准
@@ -469,16 +489,16 @@ class NewareBatteryTestSystem:
status_map = self._query_all_channels()
status_processed = {} if not status_map else self._group_by_devid(status_map)
# 修复数据过滤逻辑如果machine_id对应的数据不存在尝试使用第一个可用设备数据
status_current_machine = status_processed.get(self.machine_id, {})
# 返回主设备数据,如果主设备没有匹配数据则回退到首个可用设备
status_current_machine = status_processed.get(self.primary_device_id, {})
if not status_current_machine and status_processed:
# 如果machine_id没有匹配到数据,使用第一个可用的设备数据
# 如果主设备没有匹配到数据,使用第一个可用的设备数据
first_devid = next(iter(status_processed.keys()))
status_current_machine = status_processed[first_devid]
if self._ros_node:
self._ros_node.lab_logger().warning(
f"machine_id {self.machine_id} 没有匹配到数据使用设备ID {first_devid} 的数据"
f"主设备ID {self.primary_device_id} 没有匹配到数据使用设备ID {first_devid} 的数据"
)
# 确保有默认的数据结构
@@ -488,139 +508,57 @@ class NewareBatteryTestSystem:
"subunits": {}
}
# 确保subunits存在
subunits = status_current_machine.get("subunits", {})
# 处理2盘电池的状态映射
self._update_plate_resources(subunits)
self._update_plate_resources(status_processed)
return status_current_machine
def _update_plate_resources(self, subunits: Dict):
"""更新盘电池资源的状态"""
# 第1盘subdevid 1-5 映射到 8列5行网格 (列0-7, 行0-4)
for subdev_id in range(1, 6): # subdevid 1-5
status_row = subunits.get(subdev_id, {})
for chl_id in range(1, 9): # chlid 1-8
try:
# 根据用户描述:第一个是(0,0),最后一个是(7,4)
# 说明是8列5行列从0开始行从0开始
col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7)
row_idx = (subdev_id - 1) # 0-4 (subdevid 1-5 -> 行0-4)
# 尝试多种可能的资源命名格式
possible_names = [
f"P1_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式
f"P1_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式
f"P1_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式
]
r = None
resource_name = None
for name in possible_names:
if name in self.station_resources:
r = self.station_resources[name]
resource_name = name
break
if r:
status_channel = status_row.get(chl_id, {})
metrics = status_channel.get("metrics", {})
# 构建BatteryTestPosition状态数据移除capacity和energy
channel_state = {
# 基本测量数据
"voltage": metrics.get("voltage_V", 0.0),
"current": metrics.get("current_A", 0.0),
"time": metrics.get("totaltime_s", 0.0),
# 状态信息
"status": status_channel.get("state", "unknown"),
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
# 通道名称标识
"Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}",
}
r.load_state(channel_state)
# 调试信息
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"更新P1资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} "
f"状态:{channel_state['status']}"
def _update_plate_resources(self, status_processed: Dict[int, Dict]):
"""更新7台设备共14盘电池资源的状态"""
for devid in self.display_device_ids:
machine_data = status_processed.get(devid, {})
subunits = machine_data.get("subunits", {})
for plate_num, subdev_start, subdev_end in ((1, 1, 5), (2, 6, 10)):
for subdev_id in range(subdev_start, subdev_end + 1):
status_row = subunits.get(subdev_id, {})
for chl_id in range(1, 9):
try:
col_idx = chl_id - 1
row_idx = subdev_id - subdev_start
r, resource_name, possible_names = self._get_plate_resource(
devid=devid,
plate_num=plate_num,
row_idx=row_idx,
col_idx=col_idx
)
else:
# 如果找不到资源,记录调试信息
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"P1未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}"
)
except (KeyError, IndexError) as e:
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(f"P1映射错误: subdev{subdev_id}/chl{chl_id} - {e}")
continue
# 第2盘subdevid 6-10 映射到 8列5行网格 (列0-7, 行0-4)
for subdev_id in range(6, 11): # subdevid 6-10
status_row = subunits.get(subdev_id, {})
for chl_id in range(1, 9): # chlid 1-8
try:
col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7)
row_idx = (subdev_id - 6) # 0-4 (subdevid 6-10 -> 行0-4)
# 尝试多种可能的资源命名格式
possible_names = [
f"P2_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式
f"P2_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式
f"P2_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式
]
r = None
resource_name = None
for name in possible_names:
if name in self.station_resources:
r = self.station_resources[name]
resource_name = name
break
if r:
status_channel = status_row.get(chl_id, {})
metrics = status_channel.get("metrics", {})
# 构建BatteryTestPosition状态数据移除capacity和energy
channel_state = {
# 基本测量数据
"voltage": metrics.get("voltage_V", 0.0),
"current": metrics.get("current_A", 0.0),
"time": metrics.get("totaltime_s", 0.0),
# 状态信息
"status": status_channel.get("state", "unknown"),
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
# 通道名称标识
"Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}",
}
r.load_state(channel_state)
# 调试信息
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"更新P2资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} "
f"状态:{channel_state['status']}"
)
else:
# 如果找不到资源,记录调试信息
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"P2未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}"
)
except (KeyError, IndexError) as e:
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(f"P2映射错误: subdev{subdev_id}/chl{chl_id} - {e}")
continue
if r is None:
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"{devid}_P{plate_num}未找到资源: subdev{subdev_id}/chl{chl_id} -> "
f"尝试的名称: {possible_names}"
)
continue
status_channel = status_row.get(chl_id, {})
metrics = status_channel.get("metrics", {})
channel_state = {
"voltage": metrics.get("voltage_V", 0.0),
"current": metrics.get("current_A", 0.0),
"time": metrics.get("totaltime_s", 0.0),
"status": status_channel.get("state", "unknown"),
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
"Channel_Name": f"{devid}-{subdev_id}-{chl_id}",
}
r.load_state(channel_state)
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"更新{devid}_P{plate_num}资源状态: {resource_name} <- "
f"subdev{subdev_id}/chl{chl_id} 状态:{channel_state['status']}"
)
except (KeyError, IndexError) as e:
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"{devid}_P{plate_num}映射错误: subdev{subdev_id}/chl{chl_id} - {e}"
)
continue
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": list(self.station_resources.values())
})
@@ -640,6 +578,22 @@ class NewareBatteryTestSystem:
"""获取总通道数"""
return len(self._channels)
def _build_device_summary_dict(self) -> dict:
if not hasattr(self, '_channels') or not self._channels:
self._channels = self._build_channel_map()
channel_count_by_devid = {}
for channel in self._channels:
devid = channel.devid
channel_count_by_devid[devid] = channel_count_by_devid.get(devid, 0) + 1
return {
"channel_count_by_devid": channel_count_by_devid,
"display_device_ids": self.display_device_ids,
"total_channels": len(self._channels)
}
def device_summary(self) -> str:
return json.dumps(self._build_device_summary_dict(), ensure_ascii=False)
# ========================
# 设备动作方法Uni-Lab标准
# ========================
@@ -964,6 +918,7 @@ class NewareBatteryTestSystem:
'SIGR_LI': gen_mod.xml_SiGr_Li_Step,
'811_SIGR': gen_mod.xml_811_SiGr,
'811_CU_AGING': gen_mod.xml_811_Cu_aging,
'ZQXNLRMO':gen_mod.xml_ZQXNLRMO,
}
if key not in fmap:
raise ValueError(f"未定义电池体系映射: {key}")
@@ -1141,16 +1096,7 @@ class NewareBatteryTestSystem:
dict: ROS2动作结果格式 {"return_info": str, "success": bool}
"""
try:
# 确保_channels已初始化
if not hasattr(self, '_channels') or not self._channels:
self._channels = self._build_channel_map()
summary = {}
for channel in self._channels:
devid = channel.devid
summary[devid] = summary.get(devid, 0) + 1
result_info = json.dumps(summary, ensure_ascii=False)
result_info = self.device_summary()
success_msg = f"设备摘要统计: {result_info}"
if self._ros_node:
self._ros_node.lab_logger().info(success_msg)

View File

@@ -0,0 +1,49 @@
import socket
END_MARKS = [b"\r\n#\r\n", b"</bts>"] # 读到任一标志即可判定完整响应
def build_start_command(devid, subdevid, chlid, CoinID,
ip_in_xml="127.0.0.1",
devtype:int=27,
recipe_path:str=f"D:\\HHM_test\\A001.xml",
backup_dir:str=f"D:\\HHM_test\\backup") -> str:
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<bts version="1.0">',
' <cmd>start</cmd>',
' <list count="1">',
f' <start ip="{ip_in_xml}" devtype="{devtype}" devid="{devid}" subdevid="{subdevid}" chlid="{chlid}" barcode="{CoinID}">{recipe_path}</start>',
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="0" backupontime="1" backupontimeinterval="1" backupfree="0" />',
' </list>',
'</bts>',
]
# TCP 模式:请求必须以 #\r\n 结束(协议要求)
return "\r\n".join(lines) + "\r\n#\r\n"
def recv_until_marks(sock: socket.socket, timeout=60):
sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2}
buf = bytearray()
while True:
chunk = sock.recv(8192)
if not chunk:
break
buf += chunk
# 读到结束标志就停,避免等对端断开
for m in END_MARKS:
if m in buf:
return bytes(buf)
# 保险:读到完整 XML 结束标签也停
if b"</bts>" in buf:
return bytes(buf)
return bytes(buf)
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup"):
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir)
#print(xml_cmd)
with socket.create_connection((ip, port), timeout=60) as s:
s.sendall(xml_cmd.encode("utf-8"))
data = recv_until_marks(s, timeout=60)
return data.decode("utf-8", errors="replace")
if __name__ == "__main__":
resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup")
print(resp)

266
unilabos/devices/ros_dev/moveit_interface.py Executable file → Normal file
View File

@@ -2,7 +2,6 @@ import json
import time
from copy import deepcopy
from pathlib import Path
from typing import Optional, Sequence
from moveit_msgs.msg import JointConstraint, Constraints
from rclpy.action import ActionClient
@@ -172,160 +171,173 @@ class MoveitInterface:
return True
def pick_and_place(
self,
option: str,
move_group: str,
status: str,
resource: Optional[str] = None,
x_distance: Optional[float] = None,
y_distance: Optional[float] = None,
lift_height: Optional[float] = None,
retry: Optional[int] = None,
speed: Optional[float] = None,
target: Optional[str] = None,
constraints: Optional[Sequence[float]] = None,
) -> None:
def pick_and_place(self, command: str):
"""
使用 MoveIt 完成抓取/放置等序列pick/place/side_pick/side_place
Using MoveIt to make the robotic arm pick or place materials to a target point.
必选option, move_group, status。
可选resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
无返回值;失败时提前 return 或打印异常。
Args:
command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height
*option (string) : Action type: pick/place/side_pick/side_place
*move_group (string): The move group moveit will plan
*status(string) : Target pose
resource(string) : The target resource
x_distance (float) : The distance to the target in x direction(meters)
y_distance (float) : The distance to the target in y direction(meters)
lift_height (float) : The height at which the material should be lifted(meters)
retry (float) : Retry times when moveit plan fails
speed (float) : The speed of the movement, speed > 0
Returns:
None
"""
result = SendCmd.Result()
try:
if option not in self.move_option:
raise ValueError(f"Invalid option: {option}")
cmd_str = str(command).replace("'", '"')
cmd_dict = json.loads(cmd_str)
option_index = self.move_option.index(option)
place_flag = option_index % 2
if cmd_dict["option"] in self.move_option:
option_index = self.move_option.index(cmd_dict["option"])
place_flag = option_index % 2
config: dict = {"move_group": move_group}
if speed is not None:
config["speed"] = speed
if retry is not None:
config["retry"] = retry
config = {}
function_list = []
function_list = []
joint_positions_ = self.joint_poses[move_group][status]
status = cmd_dict["status"]
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
# 夹取 / 放置:绑定 resource 与 parent
if not place_flag:
if target is not None:
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
else:
ee = self.moveit2[move_group].end_effector_name
function_list.append(lambda r=resource: self.resource_manager(r, ee))
else:
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
joint_constraint_msgs: list = []
if constraints is not None:
for i, c in enumerate(constraints):
v = float(c)
if v > 0:
joint_constraint_msgs.append(
JointConstraint(
joint_name=self.moveit2[move_group].joint_names[i],
position=joint_positions_[i],
tolerance_above=v,
tolerance_below=v,
weight=1.0,
# 夹取
if not place_flag:
if "target" in cmd_dict.keys():
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
else:
function_list.append(
lambda: self.resource_manager(
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
)
)
else:
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
if lift_height is not None:
retval = None
attempts = config.get("retry", 10)
while retval is None and attempts > 0:
retval = self.moveit2[move_group].compute_fk(joint_positions_)
time.sleep(0.1)
attempts -= 1
if retval is None:
raise ValueError("Failed to compute forward kinematics")
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
quaternion = [
retval.pose.orientation.x,
retval.pose.orientation.y,
retval.pose.orientation.z,
retval.pose.orientation.w,
]
constraints = []
if "constraints" in cmd_dict.keys():
function_list = [
lambda: self.moveit_task(
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
quaternion=quaternion,
**config,
cartesian=self.cartesian_flag,
)
] + function_list
for i in range(len(cmd_dict["constraints"])):
v = float(cmd_dict["constraints"][i])
if v > 0:
constraints.append(
JointConstraint(
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
position=joint_positions_[i],
tolerance_above=v,
tolerance_below=v,
weight=1.0,
)
)
pose[2] += float(lift_height)
function_list.append(
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
)
)
end_pose = list(pose)
if x_distance is not None or y_distance is not None:
if x_distance is not None:
deep_pose = deepcopy(pose)
deep_pose[0] += float(x_distance)
elif y_distance is not None:
deep_pose = deepcopy(pose)
deep_pose[1] += float(y_distance)
if "lift_height" in cmd_dict.keys():
retval = None
retry = config.get("retry", 10)
while retval is None and retry > 0:
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
time.sleep(0.1)
retry -= 1
if retval is None:
result.success = False
return result
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
quaternion = [
retval.pose.orientation.x,
retval.pose.orientation.y,
retval.pose.orientation.z,
retval.pose.orientation.w,
]
function_list = [
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
lambda: self.moveit_task(
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
quaternion=quaternion,
**config,
cartesian=self.cartesian_flag,
)
] + function_list
pose[2] += float(cmd_dict["lift_height"])
function_list.append(
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
lambda: self.moveit_task(
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
)
)
end_pose = list(deep_pose)
end_pose = pose
retval_ik = None
attempts_ik = config.get("retry", 10)
while retval_ik is None and attempts_ik > 0:
retval_ik = self.moveit2[move_group].compute_ik(
position=end_pose,
quat_xyzw=quaternion,
constraints=Constraints(joint_constraints=joint_constraint_msgs),
)
time.sleep(0.1)
attempts_ik -= 1
if retval_ik is None:
raise ValueError("Failed to compute inverse kinematics")
position_ = [
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
]
jn = self.moveit2[move_group].joint_names
function_list = [
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
joint_positions=pos, joint_names=names, **cfg
)
] + function_list
else:
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
if "x_distance" in cmd_dict.keys():
deep_pose = deepcopy(pose)
deep_pose[0] += float(cmd_dict["x_distance"])
elif "y_distance" in cmd_dict.keys():
deep_pose = deepcopy(pose)
deep_pose[1] += float(cmd_dict["y_distance"])
for i in range(len(function_list)):
if i == 0:
self.cartesian_flag = False
function_list = [
lambda: self.moveit_task(
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
)
] + function_list
function_list.append(
lambda: self.moveit_task(
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
)
)
end_pose = deep_pose
retval_ik = None
retry = config.get("retry", 10)
while retval_ik is None and retry > 0:
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
)
time.sleep(0.1)
retry -= 1
if retval_ik is None:
result.success = False
return result
position_ = [
retval_ik.position[retval_ik.name.index(i)]
for i in self.moveit2[cmd_dict["move_group"]].joint_names
]
function_list = [
lambda: self.moveit_joint_task(
joint_positions=position_,
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
**config,
)
] + function_list
else:
self.cartesian_flag = True
function_list = [
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
] + function_list
re = function_list[i]()
if not re:
print(i, re)
raise ValueError(f"Failed to execute moveit task: {i}")
for i in range(len(function_list)):
if i == 0:
self.cartesian_flag = False
else:
self.cartesian_flag = True
re = function_list[i]()
if not re:
print(i, re)
result.success = False
return result
result.success = True
except Exception as e:
print(e)
self.cartesian_flag = False
raise e
result.success = False
return result
def set_status(self, command: str):
"""

View File

@@ -0,0 +1,88 @@
"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo"""
import asyncio
import logging
import random
import time
from typing import Any, Dict, List, Optional
class VirtualSampleDemo:
"""虚拟样品追踪演示设备,提供两种典型返回模式:
- measure_samples: 等长输入输出 (前端按 index 自动对齐)
- split_and_measure: 输出比输入长,附带 samples 列标注归属
"""
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")
self.device_id = device_id or "unknown_sample_demo"
self.config = config or {}
self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}")
self.data: Dict[str, Any] = {"status": "Idle"}
# ------------------------------------------------------------------
# Action 1: 等长输入输出,无 samples 列
# ------------------------------------------------------------------
async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]:
"""模拟光度测量。absorbance = concentration * 0.05 + noise
入参和出参 list 长度相等,前端按 index 自动对齐。
"""
self.logger.info(f"measure_samples: concentrations={concentrations}")
absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations]
return {"concentrations": concentrations, "absorbance": absorbance}
# ------------------------------------------------------------------
# Action 2: 输出比输入长,带 samples 列
# ------------------------------------------------------------------
async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]:
"""将每个样品均分为 split_count 份后逐份测量。
返回的 list 长度 = len(volumes) * split_count
附带 samples 列标注每行属于第几个输入样品 (0-based index)。
"""
self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}")
out_volumes: List[float] = []
readings: List[float] = []
samples: List[int] = []
for idx, vol in enumerate(volumes):
split_vol = round(vol / split_count, 2)
for _ in range(split_count):
out_volumes.append(split_vol)
readings.append(round(random.uniform(0.1, 1.0), 4))
samples.append(idx)
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
# ------------------------------------------------------------------
# Action 3: 入参和出参都带 samples 列(不等长)
# ------------------------------------------------------------------
async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]:
"""对 split_and_measure 的输出做二次分析。
入参 readings/samples 长度相同但 > 原始样品数,
出参同样带 samples 列,长度与入参一致。
"""
self.logger.info(f"analyze_readings: readings={readings}, samples={samples}")
scores: List[float] = []
passed: List[bool] = []
threshold = 0.4
for r in readings:
score = round(r * 100 + random.gauss(0, 2), 2)
scores.append(score)
passed.append(r >= threshold)
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
# ------------------------------------------------------------------
# 状态属性
# ------------------------------------------------------------------
@property
def status(self) -> str:
return self.data.get("status", "Idle")

View File

@@ -258,7 +258,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.info(f"[同步→Bioyond] 物料不存在于 Bioyond将创建新物料并入库")
# 第1步从配置中获取仓库配置
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
# 确定目标仓库名称
parent_name = None
@@ -760,10 +760,9 @@ class BioyondWorkstation(WorkstationBase):
except:
pass
# 创建通信模块
# 创建通信模块;同步器将在 post_init 中初始化并执行首次同步
self._create_communication_module(bioyond_config)
self.resource_synchronizer = BioyondResourceSynchronizer(self)
self.resource_synchronizer.sync_from_external()
self.resource_synchronizer = None
# TODO: self._ros_node里面拿属性
@@ -802,6 +801,15 @@ class BioyondWorkstation(WorkstationBase):
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化仓库
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
self.deck.setup()
# 初始化同步器并执行首次同步(需在仓库初始化之后)
self.resource_synchronizer = BioyondResourceSynchronizer(self)
self.resource_synchronizer.sync_from_external()
# 启动连接监控
try:
self.connection_monitor = ConnectionMonitor(self)

View File

@@ -0,0 +1,219 @@
# 代码变更说明 — 2026-03-12
> 本次变更基于 `implementation_plan_v2.md` 执行,目标:**物理几何结构初始化与物料内容物填充彻底解耦**,消除 PLR 反序列化时的 `Resource already assigned to deck` 错误,并修复若干运行时新增问题。
---
## 一、物料系统标准化重构(主线任务)
### 1. `unilabos/resources/battery/magazine.py`
**改动**`MagazineHolder_6_Cathode``MagazineHolder_6_Anode``MagazineHolder_4_Cathode` 三个工厂函数的 `klasses` 参数改为 `None`
**原因**:原来三个工厂函数在初始化时就向洞位填满极片对象(`ElectrodeSheet`),导致 PLR 反序列化时"几何结构已创建子节点 + DB 再次 assign"双重冲突。
**原则**:物料余量改由寄存器直读(阶段 F资源树不再追踪每个极片实体。`MagazineHolder_6_Battery` 原本就是 `klasses=None`,三者现在保持一致。
---
### 2. `unilabos/resources/battery/magazine.py`(追加,响应重复 UUID 问题)
**改动**:为 `Magazine`(洞位类)新增 `serialize``deserialize` 重写:
- `serialize`:序列化时强制将 `children` 置空,不再把极片写回数据库。
- `deserialize`:反序列化时强制忽略 `children` 字段,阻止数据库中旧极片记录被恢复。
**原因**:数据库中遗留有旧的 `ElectrodeSheet` 记录(`A1_sheet100` 等),启动时被 PLR 反序列化进来,导致同一 UUID 出现在多个 Magazine 洞位中,触发 `发现重复的uuid` 错误。此修复从源头截断旧数据,经过一次完整的"启动 → 资源树写回"后,数据库旧极片记录也会被干净覆盖。
---
### 3. `unilabos/resources/battery/bottle_carriers.py`
**改动**:删除 `YIHUA_Electrolyte_12VialCarrier` 末尾的 12 瓶填充循环及对应 `import`
**原因**`bottle_rack_6x2``bottle_rack_6x2_2` 应初始化为空载架,瓶子由 Bioyond 侧实际转运后再填入。原来初始化时直接塞满 `YB_pei_ye_xiao_Bottle`,反序列化时产生重复 assign。
---
### 4. `unilabos/resources/bioyond/decks.py`
**改动**
-`BIOYOND_YB_Deck` 重命名为 `BioyondElectrolyteDeck`,保留 `BIOYOND_YB_Deck` 作为向后兼容别名。
- 工厂函数 `YB_Deck()` 重命名为 `bioyond_electrolyte_deck()`,保留 `YB_Deck` 作为别名。
- `BIOYOND_PolymerReactionStation_Deck``BIOYOND_PolymerPreparationStation_Deck``BioyondElectrolyteDeck` 三个 Deck 类:
- 移除 `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用。
- 删除临时 `deserialize` 补丁(该补丁是为了强制 `setup=False`,根本原因消除后不再需要)。
**原因**`setup` 参数导致 PLR 反序列化时先通过 `__init__` 创建所有子资源,再从 JSON `children` 字段再次 assign产生 `already assigned to deck` 错误。正确模式:`__init__` 只初始化自身几何,`setup()` 由工厂函数调用,反序列化由 PLR 从 DB 数据重建子资源。
---
### 5. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
**改动**
- `CoincellDeck` 重命名为 `YihuaCoinCellDeck`,保留 `CoincellDeck` 作为向后兼容别名。
- 工厂函数 `YH_Deck()` 重命名为 `yihua_coin_cell_deck()`,保留 `YH_Deck` 作为别名。
- 移除 `YihuaCoinCellDeck.__init__` 中的 `setup: bool = False` 参数及调用,删除 `deserialize` 补丁(原因同 decks.py
- `MaterialPlate.__init__` 移除 `fill` 参数和 `fill=True` 分支,新增类方法 `MaterialPlate.create_with_holes()` 作为"带洞位"的工厂方法,`setup()` 改为调用该工厂方法。
- `YihuaCoinCellDeck.setup()` 末尾新增 `electrolyte_buffer``ResourceStack`)接驳槽,用于接收来自 Bioyond 侧的分液瓶板,命名与 `bioyond_cell_workstation.py``sites=["electrolyte_buffer"]` 一致。
---
### 6. `unilabos/resources/resource_tracker.py`
**改动 1**`to_plr_resources` 中,`load_all_state` 调用前预填 `Container` 类资源缺失的键:
```python
state.setdefault("liquid_history", [])
state.setdefault("pending_liquids", {})
```
**原因**:新版 PLR 要求 `Container` 状态中必须包含这两个键,旧数据库记录缺失时 `load_all_state` 会抛出 `KeyError`
**改动 2**`_validate_tree` 中,遇到重复 UUID 时改为自动重新分配新 UUID 并打 `WARNING`,不再直接抛异常崩溃。
**原因**:旧数据库中存在多个同名同 UUID 的极片对象(历史脏数据),严格校验会导致节点无法启动。改为 WARNING + 自动修复,确保启动成功,下次资源树写回后脏数据自然清除。
---
### 7. `unilabos/resources/itemized_carrier.py`
**改动**:将原来的 `idx is None` 兜底补丁(静默调用 `super().assign_child_resource`,不更新槽位追踪)替换为两段式逻辑:
1. **XY 近似匹配**(容差 2mm精确三维坐标匹配失败时仅对比 XY 二维坐标,找到最近槽位后用槽位的正确坐标(含 Z完成 assign并打 `WARNING`
2. **XY 也失败才抛异常**:给出详细的槽位列表和传入坐标,便于问题排查。
**原因**:数据库中存储的资源坐标 Z=0`warehouse_factory` 定义的槽位 Z=dz如 10mm。精确匹配永远失败原补丁静默兜底掩盖了这一问题。近似匹配修复了 Z 偏移,同时保留了真正异常时的报错能力。
---
### 8. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
**改动 1**:更新导入:`BIOYOND_YB_Deck``BioyondElectrolyteDeck, bioyond_electrolyte_deck`
**改动 2**`__main__` 入口处改为调用 `bioyond_electrolyte_deck(name="YB_Deck")`
**改动 3**:新增 `_get_resource_from_device(device_id, resource_name)` 方法,用于从目标设备的资源树中动态查找 PLR 资源对象(带降级回退逻辑)。
**改动 4**:跨站转运逻辑中,将原来"创建 `size=1,1,1` 的虚拟 `ResourcePLR` + 硬编码 UUID"的方式,改为通过 `_get_resource_from_device` 从目标设备获取真实的 `electrolyte_buffer` 资源对象。
**原因**:原代码使用硬编码 UUID 的虚拟资源作为转运目标,该对象在 YihuaCoinCellDeck 的资源树中不存在,转移后资源树状态混乱。
---
### 9. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
**改动 1**:更新导入:`CoincellDeck``YihuaCoinCellDeck, yihua_coin_cell_deck``__main__` 入口改为调用 `yihua_coin_cell_deck()`
**改动 2**:新增 10 个 `@property`,实现对依华扣电工站 Modbus 寄存器的直读:
| 属性名 | 寄存器地址 | 说明 |
|---|---|---|
| `data_10mm_positive_plate_remaining` | 520 | 10mm正极片余量 |
| `data_12mm_positive_plate_remaining` | 522 | 12mm正极片余量 |
| `data_16mm_positive_plate_remaining` | 524 | 16mm正极片余量 |
| `data_aluminum_foil_remaining` | 526 | 铝箔余量 |
| `data_positive_shell_remaining` | 528 | 正极壳余量 |
| `data_flat_washer_remaining` | 530 | 平垫余量 |
| `data_negative_shell_remaining` | 532 | 负极壳余量 |
| `data_spring_washer_remaining` | 534 | 弹垫余量 |
| `data_finished_battery_remaining_capacity` | 536 | 成品电池余量 |
| `data_finished_battery_ng_remaining_capacity` | 538 | 成品电池NG槽余量 |
**原因**`coin_cell_workstation.yaml``status_types` 中定义了这 10 个属性,但代码中从未实现,导致每次前端轮询时均报 `AttributeError`
---
## 二、配置与注册表更新
### 10. `yibin_electrolyte_config.json`
- `BIOYOND_YB_Deck``BioyondElectrolyteDeck`class、type、_resource_type 三处)
- `CoincellDeck``YihuaCoinCellDeck`class、type、_resource_type 三处)
- 移除 `"setup": true` 字段
### 11. `yibin_coin_cell_only_config.json`
- `CoincellDeck``YihuaCoinCellDeck`
- 移除 `"setup": true`
### 12. `yibin_electrolyte_only_config.json`
- `BIOYOND_YB_Deck``BioyondElectrolyteDeck`
- 移除 `"setup": true`
### 13. `unilabos/registry/resources/bioyond/deck.yaml`
- `BIOYOND_YB_Deck``BioyondElectrolyteDeck`,工厂函数路径更新为 `bioyond_electrolyte_deck`
- `CoincellDeck``YihuaCoinCellDeck`,工厂函数路径更新为 `yihua_coin_cell_deck`
---
## 三、独立 Bug 修复
### 14. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv`
**改动**10 条余量寄存器记录的 `DataType` 列从 `REAL` 改为 `FLOAT32`
**原因**`REAL` 是 IEC 61131-3 PLC 工程师惯用名称,但 pymodbus 的 `DATATYPE` 枚举只有 `FLOAT32``DataType['REAL']` 查表时抛 `KeyError: 'REAL'`,导致 `CoinCellAssemblyWorkstation` 节点启动失败。
---
## 四、运行期新增 Bug 修复第二轮2026-03-12 18:12 日志)
### 15. `unilabos/devices/workstation/bioyond_studio/station.py`
**改动**:第 261 行 `self.bioyond_config``self.workstation.bioyond_config`
**原因**`BioyondResourceSynchronizer.sync_to_external` 内部误用了 `self.bioyond_config`,而该类从未设置此属性(应通过 `self.workstation.bioyond_config` 访问)。触发场景:用户在前端将任意物料拖入仓库时,同步到 Bioyond 必定抛出 `AttributeError: 'BioyondResourceSynchronizer' object has no attribute 'bioyond_config'`
---
### 16. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
**改动**`_get_type_id_by_name` 方法新增"直接英文 key 命中"分支:
- **原逻辑**:仅按 `value[0]`(中文名,如 `"5ml分液瓶板"`)遍历比较。
- **新逻辑**:先以 `type_name` 直接查找 `material_type_mappings` 字典 key英文 model 名,如 `"YB_Vial_5mL_Carrier"`),命中则立即返回 UUID否则再按中文名兜底遍历。
**原因**`resource_tree_transfer``plr_resource.model`(英文 key作为 `board_type` / `bottle_type` 传给 `create_sample`,后者再调用 `_get_type_id_by_name`。旧版函数只按中文名查,导致英文 key 永远匹配不到 → `ValueError: 未找到板类型 'YB_Vial_5mL_Carrier' 的配置`。新函数兼容两种查找方式,同时保持向后兼容。
---
## 五、运行期新增 Bug 修复第三轮2026-03-12 20:30 日志)
### 17. `unilabos/resources/resource_tracker.py`(追加)
**改动**:在 `to_plr_resources` 中,`sub_cls.deserialize` 调用前新增 `_deduplicate_plr_dict(plr_dict)` 预处理函数。
**函数逻辑**:递归遍历整个 `plr_dict` 树,在**全树范围**对 `children` 列表按 `name` 去重——保留首次出现的同名节点,跳过重复项并打 `WARNING`
**根本原因**
1. 用户通过前端将 `YB_Vial_5mL_Carrier` 拖入仓库 E01carrier 及其子 vial`YB_Vial_5mL_Carrier_vial_A1` 等)被写入数据库。
2. 随后 `sync_from_external`Bioyond 定期同步)以**新 UUID** 重新创建同名 carrier 并赋给同一槽位PLR 内存树中的旧 carrier 被替换,但**数据库旧记录未被清除**。
3. 下次重启时,数据库同一 `WareHouse` 下存在两条同名 `BottleCarrier`(不同 UUID`node_to_plr_dict` 将二者都放入 `children` 列表PLR 反序列化第二个 carrier 时子 vial 命名冲突,抛出 `ValueError: Resource with name 'YB_Vial_5mL_Carrier_vial_A1' already exists in the tree.`,整个 deck 无法加载,系统启动失败。
**连锁错误(随根因修复自动消除)**
- `TypeError: Deck.__init__() got an unexpected keyword argument 'data'` — deck 加载失败后 `driver_creator.py` 触发降级路径,参数类型错误
- `AttributeError: 'ResourceDictInstance' object has no attribute 'copy'` — 另一条降级路径失败
- `ValueError: Deck 配置不能为空` — 所有 deck 创建路径失败,`deck=None` 传入工作站
---
> **验证状态**2026-03-12 20:56 日志确认系统正常运行,无新增 ERROR 级错误。
---
## 六、变更文件汇总(最终)
| 文件 | 变更类型 | 轮次 |
|---|---|---|
| `resources/battery/magazine.py` | 重构 + Bug 修复(极片子节点解耦 + 旧数据清理) | 第一轮 |
| `resources/battery/bottle_carriers.py` | 重构(移除初始化时自动填瓶) | 第一轮 |
| `resources/bioyond/decks.py` | 重构 + 重命名BioyondElectrolyteDeck | 第一轮 |
| `devices/workstation/coin_cell_assembly/YB_YH_materials.py` | 重构 + 重命名YihuaCoinCellDeck+ 新增 electrolyte_buffer 槽位 | 第一轮 |
| `resources/resource_tracker.py` | Bug 修复 × 3Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重) | 第一/三轮 |
| `resources/itemized_carrier.py` | Bug 修复XY 近似坐标匹配,修复 Z 偏移) | 第一轮 |
| `devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | 重构 + Bug 修复(跨站转运 + 类型映射双模式查找) | 第一/二轮 |
| `devices/workstation/bioyond_studio/station.py` | Bug 修复sync_to_external 属性访问路径) | 第二轮 |
| `devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | 新增 10 个 Modbus 余量属性 + 更新导入 | 第一轮 |
| `yibin_electrolyte_config.json` | 配置更新(类名 + 移除 setup | 第一轮 |
| `yibin_coin_cell_only_config.json` | 配置更新(类名 + 移除 setup | 第一轮 |
| `yibin_electrolyte_only_config.json` | 配置更新(类名 + 移除 setup | 第一轮 |
| `registry/resources/bioyond/deck.yaml` | 注册表更新(类名 + 工厂函数路径) | 第一轮 |
| `devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` | Bug 修复REAL → FLOAT32 | 第一轮 |

View File

@@ -130,20 +130,14 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
ordering: Optional[OrderedDict[str, str]] = None,
category: str = "material_plate",
model: Optional[str] = None,
fill: bool = False
):
"""初始化料板
"""初始化料板(不主动填充洞位,由工厂方法或反序列化恢复)
Args:
name: 料板名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
hole_diameter: 洞直径 (mm)
hole_depth: 洞深度 (mm)
hole_spacing_x: X方向洞位间距 (mm)
hole_spacing_y: Y方向洞位间距 (mm)
number: 编号
category: 类别
model: 型号
"""
@@ -153,42 +147,50 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
hole_diameter=20.0,
info="",
)
# 创建4x4的洞位
# TODO: 这里要改,对应不同形状
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,
)
@classmethod
def create_with_holes(
cls,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "material_plate",
model: Optional[str] = None,
) -> "MaterialPlate":
"""工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup不在反序列化路径调用"""
# 默认洞位间距(与 _unilabos_state 默认值保持一致)
hole_spacing_x = 24.0
hole_spacing_y = 24.0
# 先建洞位,再作为 ordered_items 传入构造函数
# ItemizedResource.__init__ 要求 ordered_items 或 ordering 二选一必须有值)
holes = create_ordered_items_2d(
klass=MaterialHole,
num_items_x=4,
num_items_y=4,
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
dx=(size_x - 4 * hole_spacing_x) / 2,
dy=(size_y - 4 * hole_spacing_y) / 2,
dz=size_z,
item_dx=self._unilabos_state["hole_spacing_x"],
item_dy=self._unilabos_state["hole_spacing_y"],
size_x = 16,
size_y = 16,
size_z = 16,
item_dx=hole_spacing_x,
item_dy=hole_spacing_y,
size_x=16,
size_y=16,
size_z=16,
)
return cls(
name=name, size_x=size_x, size_y=size_y, size_z=size_z,
ordered_items=holes, category=category, model=model,
)
if fill:
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=holes,
category=category,
model=model,
)
else:
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=ordered_items,
ordering=ordering,
category=category,
model=model,
)
def update_locations(self):
# TODO:调多次相加
@@ -534,30 +536,19 @@ class WasteTipBox(Trash):
return data
class CoincellDeck(Deck):
"""纽扣电池组装工作站台面类"""
class YihuaCoinCellDeck(Deck):
"""依华纽扣电池组装工作站台面类"""
def __init__(
self,
name: str = "coin_cell_deck",
size_x: float = 1450.0, # 1m
size_y: float = 1450.0, # 1m
size_z: float = 100.0, # 0.9m
size_x: float = 1450.0,
size_y: float = 1450.0,
size_z: float = 100.0,
origin: Coordinate = Coordinate(-2200, 0, 0),
category: str = "coin_cell_deck",
setup: bool = False, # 是否自动执行 setup
setup: bool = False,
):
"""初始化纽扣电池组装工作站台面
Args:
name: 台面名称
size_x: 长度 (mm) - 1m
size_y: 宽度 (mm) - 1m
size_z: 高度 (mm) - 0.9m
origin: 原点坐标
category: 类别
setup: 是否自动执行 setup 配置标准布局
"""
super().__init__(
name=name,
size_x=1450.0,
@@ -591,14 +582,11 @@ class CoincellDeck(Deck):
# ====================================== 物料板 ============================================
# 创建物料板料盘carrier- 4x4布局
# 负极料盘
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
fujiliaopan = MaterialPlate.create_with_holes(name="负极料盘", size_x=120, size_y=100, size_z=10.0)
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
# for i in range(16):
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
# 隔膜料盘
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
gemoliaopan = MaterialPlate.create_with_holes(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0)
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
# for i in range(16):
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
@@ -633,11 +621,27 @@ class CoincellDeck(Deck):
waste_tip_box = WasteTipBox(name="waste_tip_box")
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
# 分液瓶板接驳区 - 接收来自 BioyondElectrolyte 侧的完整 Vial Carrier 板
# 命名 electrolyte_buffer 与 bioyond_cell_workstation.py 中 sites=["electrolyte_buffer"] 对应
electrolyte_buffer = ResourceStack(
name="electrolyte_buffer",
direction="z",
resources=[],
)
self.assign_child_resource(electrolyte_buffer, Coordinate(x=1050.0, y=700.0, z=0))
def YH_Deck(name=""):
cd = CoincellDeck(name=name)
cd.setup()
return cd
def yihua_coin_cell_deck(name: str = "coin_cell_deck") -> YihuaCoinCellDeck:
deck = YihuaCoinCellDeck(name=name)
deck.setup()
return deck
# 向后兼容别名,日后废弃
CoincellDeck = YihuaCoinCellDeck
def YH_Deck(name: str = "") -> YihuaCoinCellDeck:
return yihua_coin_cell_deck(name=name or "coin_cell_deck")
if __name__ == "__main__":

View File

@@ -17,7 +17,7 @@ from unilabos.device_comms.modbus_plc.modbus import DeviceType, Base as ModbusNo
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import *
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck, yihua_coin_cell_deck
from unilabos.resources.graphio import convert_resources_to_type
from unilabos.utils.log import logger
import struct
@@ -161,7 +161,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
logger.info("没有传入依华deck检查启动json文件")
super().__init__(deck=deck, *args, **kwargs,)
self.debug_mode = debug_mode
self._modbus_address = address
self._modbus_port = port
""" 连接初始化 """
modbus_client = TCPClient(addr=address, port=port)
logger.debug(f"创建 Modbus 客户端: {modbus_client}")
@@ -178,9 +180,11 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
raise ValueError('modbus tcp connection failed')
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_b.csv'))
self.client = modbus_client.register_node_list(self.nodes)
self._modbus_client_raw = modbus_client
else:
print("测试模式,跳过连接")
self.nodes, self.client = None, None
self._modbus_client_raw = None
""" 工站的配置 """
@@ -191,9 +195,40 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
self.csv_export_file = None
self.coin_num_N = 0 #已组装电池数量
def _ensure_modbus_connected(self) -> None:
"""检查 Modbus TCP 连接是否存活,若已断开则自动重连(防止长时间空闲后连接超时)"""
if self.debug_mode or self._modbus_client_raw is None:
return
raw_client = self._modbus_client_raw.client
if raw_client.is_socket_open():
return
logger.warning("[Modbus] 检测到连接已断开,尝试重连...")
try:
raw_client.close()
except Exception:
pass
count = 10
while count > 0:
count -= 1
try:
raw_client.connect()
except Exception:
pass
if raw_client.is_socket_open():
break
time.sleep(2)
if not raw_client.is_socket_open():
raise RuntimeError(f"Modbus TCP 重连失败({self._modbus_address}:{self._modbus_port}),请检查设备连接")
logger.info("[Modbus] 重连成功")
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
#self.deck = create_a_coin_cell_deck()
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化子物料
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
self.deck.setup()
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
})
@@ -623,12 +658,28 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
return vol
@property
def data_coin_num(self) -> int:
"""当前电池数量 (INT16)"""
def data_coin_type(self) -> int:
"""电池类型 - 7种或8种组装物料 (INT16)"""
if self.debug_mode:
return 7
coin_type, read_err = self.client.use_node('REG_DATA_COIN_TYPE').read(1)
return coin_type
@property
def data_current_assembling_count(self) -> int:
"""当前进行组装的电池数量 - Current assembling battery count (INT16)"""
if self.debug_mode:
return 0
num, read_err = self.client.use_node('REG_DATA_COIN_NUM').read(1)
return num
count, read_err = self.client.use_node('REG_DATA_CURRENT_ASSEMBLING_COUNT').read(1)
return count
@property
def data_current_completed_count(self) -> int:
"""当前完成组装的电池数量 - Current completed battery count (INT16)"""
if self.debug_mode:
return 0
count, read_err = self.client.use_node('REG_DATA_CURRENT_COMPLETED_COUNT').read(1)
return count
@property
def data_coin_cell_code(self) -> str:
@@ -726,6 +777,116 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_10mm_positive_plate_remaining(self) -> float:
"""10mm正极片剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取10mm正极片余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_12mm_positive_plate_remaining(self) -> float:
"""12mm正极片剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取12mm正极片余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_16mm_positive_plate_remaining(self) -> float:
"""16mm正极片剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取16mm正极片余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_aluminum_foil_remaining(self) -> float:
"""铝箔剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取铝箔余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_positive_shell_remaining(self) -> float:
"""正极壳剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_POSITIVE_SHELL_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取正极壳余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_flat_washer_remaining(self) -> float:
"""平垫剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FLAT_WASHER_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取平垫余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_negative_shell_remaining(self) -> float:
"""负极壳剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取负极壳余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_spring_washer_remaining(self) -> float:
"""弹垫剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_SPRING_WASHER_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取弹垫余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_finished_battery_remaining_capacity(self) -> float:
"""成品电池剩余可容纳数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY').address, count=2)
if result.isError():
logger.error("读取成品电池余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_finished_battery_ng_remaining_capacity(self) -> float:
"""成品电池NG槽剩余可容纳数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY').address, count=2)
if result.isError():
logger.error("读取成品电池NG槽余量失败")
return 0.0
return _decode_float32_correct(result.registers)
# @property
# def data_stack_vision_code(self) -> int:
# """物料堆叠复检图片编码 (INT16)"""
@@ -925,6 +1086,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
# 步骤0: 前置条件检查
logger.info("\n【步骤 0/4】前置条件检查...")
self._ensure_modbus_connected()
try:
# 检查 REG_UNILAB_INTERACT (应该为False表示使用Unilab交互)
unilab_interact_node = self.client.use_node('REG_UNILAB_INTERACT')
@@ -985,6 +1147,42 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
raise RuntimeError(error_msg)
logger.info(" ✓ COIL_GB_L_IGNORE_CMD 检查通过 (值为False使用左手套箱)")
# 检查握手寄存器残留正常初始状态均应为False
# 若上次运行意外断网这些Unilab侧COIL可能被遗留为True导致PLC逻辑卡死
handshake_checks = [
("COIL_UNILAB_SEND_MSG_SUCC_CMD", "Unilab→PLC 配方发送完毕", "上次配方握手未正常复位PLC可能处于等待配方的卡死状态"),
("COIL_UNILAB_REC_MSG_SUCC_CMD", "Unilab→PLC 数据接收完毕", "上次数据接收握手未正常复位"),
("UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM", "Unilab→PLC 瓶数发送完毕", "上次瓶数握手未正常复位"),
("UNILAB_SEND_FINISHED_CMD", "Unilab→PLC 一组完成确认", "上次完成握手未正常复位"),
("COIL_REQUEST_REC_MSG_STATUS", "PLC→Unilab 请求接收配方", "PLC正处于等待配方状态设备流程已卡死需重启PLC或手动复位握手"),
("COIL_REQUEST_SEND_MSG_STATUS", "PLC→Unilab 请求发送测试数据", "PLC正处于等待发送数据状态设备流程已卡死"),
]
for coil_name, coil_desc, stuck_reason in handshake_checks:
try:
hs_node = self.client.use_node(coil_name)
hs_value, hs_err = hs_node.read(1)
if hs_err:
logger.warning(f" ⚠ 无法读取 {coil_name},跳过此项检查")
continue
hs_actual = hs_value[0] if isinstance(hs_value, (list, tuple)) else hs_value
logger.info(f" {coil_name} 当前值: {hs_actual}")
if hs_actual:
error_msg = (
"❌ 前置握手寄存器检查失败!\n"
f" {coil_name} = True (期望值: False)\n"
f" 含义: {coil_desc}\n"
f" 原因: {stuck_reason}\n"
" 建议: 检查上次运行是否意外中断手动将该寄存器置为False后重试"
)
logger.error(error_msg)
raise RuntimeError(error_msg)
logger.info(f"{coil_name} 检查通过 (值为False)")
except RuntimeError:
raise
except Exception as hs_e:
logger.warning(f" ⚠ 检查 {coil_name} 时发生异常: {hs_e},跳过此项")
logger.info("✓ 所有前置条件检查通过!")
except ValueError as e:
@@ -1158,7 +1356,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
lvbodian: bool = True,
battery_pressure_mode: bool = True,
battery_clean_ignore: bool = False,
file_path: str = "/Users/sml/work"
file_path: str = "/Users/sml/work",
formulations: List[Dict] = None
) -> Dict[str, Any]:
"""
发送瓶数+简化组装函数(适用于第二批次及后续批次)
@@ -1185,17 +1384,44 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
battery_pressure_mode: 是否启用压力模式
battery_clean_ignore: 是否忽略电池清洁
file_path: 实验记录保存路径
formulations: 配方信息列表(从 create_orders.mass_ratios 获取)
包含 orderCode, target_mass_ratio, real_mass_ratio 等
用于CSV数据追溯可选参数
Returns:
dict: 包含组装结果的字典
注意
注意:
- 第一次启动需先调用 func_pack_device_init_auto_start_combined()
- 后续批次直接调用此函数即可
"""
logger.info("=" * 60)
logger.info("开始发送瓶数+简化组装流程...")
logger.info(f"电解液瓶数: {elec_num}, 每瓶电池数: {elec_use_num}")
# 存储配方信息到设备状态(供 CSV 写入使用)
if formulations:
logger.info(f"接收到配方信息: {len(formulations)}")
# 将配方信息按 orderCode 索引,方便后续查找
self._formulations_map = {
f["orderCode"]: f for f in formulations
} if formulations else {}
# ✅ 新增:存储配方列表(按接收顺序),用于索引访问
self._formulations_list = formulations
else:
logger.warning("未接收到配方信息CSV将不包含配方字段")
self._formulations_map = {}
self._formulations_list = []
# ✅ 新增:存储每瓶电池数,用于计算当前使用的瓶号
# ⚠️ 确保转换为整数(前端可能传递字符串)
self._elec_use_num = int(elec_use_num) if elec_use_num else 0
logger.info(f"已存储参数: 每瓶电池数={self._elec_use_num}, 配方数={len(self._formulations_list)}")
# ✅ 新增:软件层电池计数器(防止硬件计数器不准确)
self._software_battery_counter = 0 # 从0开始每写入一次CSV递增
logger.info("软件层电池计数器已初始化")
logger.info("=" * 60)
# 步骤1: 发送电解液瓶数(触发物料搬运)
@@ -1331,7 +1557,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
data_assembly_time = self.data_assembly_time
data_assembly_pressure = self.data_assembly_pressure
data_electrolyte_volume = self.data_electrolyte_volume
data_coin_num = self.data_coin_num
data_coin_type = self.data_coin_type # 电池类型7或8种物料
data_battery_number = self.data_current_assembling_count # ✅ 真正的电池编号
# 处理电解液二维码 - 确保是字符串类型
try:
@@ -1361,28 +1588,32 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
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_coin_type: {data_coin_type}") # 电池类型
logger.debug(f"data_battery_number: {data_battery_number}") # ✅ 电池编号
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("成品弹夹")
finished_battery_magazine = self.deck.get_resource("成品弹夹")
# 计算电池应该放在哪个洞,以及洞内的堆叠位置
# 成品弹夹有6个洞每个洞可堆叠20颗电池
# 前5个洞索引0-4放正常电池第6个洞索引5放NG电池
BATTERIES_PER_HOLE = 20
MAX_NORMAL_BATTERIES = 100 # 5个洞 × 20颗/洞
hole_index = self.coin_num_N // BATTERIES_PER_HOLE # 第几个洞0-4为正常电池
in_hole_position = self.coin_num_N % BATTERIES_PER_HOLE # 洞内的堆叠序号
if hole_index >= 5:
logger.error(f"电池数量超出正常容量范围: {self.coin_num_N + 1} > {MAX_NORMAL_BATTERIES}")
raise ValueError(f"成品弹夹正常洞位已满(最多{MAX_NORMAL_BATTERIES}颗),当前尝试放置第{self.coin_num_N + 1}")
target_hole = finished_battery_magazine.children[hole_index] # 获取目标洞
# 生成唯一的电池名称(使用时间戳确保唯一性)
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
battery_name = f"battery_{self.coin_num_N}_{timestamp_suffix}"
# 检查目标位置是否已有资源,如果有则先卸载
target_slot = liaopan3.children[self.coin_num_N]
if target_slot.children:
logger.warning(f"位置 {self.coin_num_N} 已有资源,将先卸载旧资源")
try:
# 卸载所有现有子资源
for child in list(target_slot.children):
target_slot.unassign_child_resource(child)
logger.info(f"已卸载旧资源: {child.name}")
except Exception as e:
logger.error(f"卸载旧资源时出错: {e}")
# 创建新的电池资源
battery = ElectrodeSheet(name=battery_name, size_x=14, size_y=14, size_z=2)
battery._unilabos_state = {
@@ -1393,13 +1624,12 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
"electrolyte_volume": data_electrolyte_volume
}
# 分配新资源到目标位置
# 将电池堆叠到目标洞中
try:
target_slot.assign_child_resource(battery, location=None)
logger.info(f"成功分配电池 {battery_name}位置 {self.coin_num_N}")
target_hole.assign_child_resource(battery, location=None)
logger.info(f"成功放置电池 {battery_name}弹夹洞{hole_index}的第{in_hole_position + 1}层 (总计第{self.coin_num_N + 1}颗)")
except Exception as e:
logger.error(f"分配电池资源失败: {e}")
# 如果分配失败,尝试使用更简单的方法
logger.error(f"放置电池资源失败: {e}")
raise
#print(jipian2.parent)
@@ -1430,17 +1660,79 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
writer.writerow([
'Time', 'open_circuit_voltage', 'pole_weight',
'assembly_time', 'assembly_pressure', 'electrolyte_volume',
'coin_num', 'electrolyte_code', 'coin_cell_code'
'coin_num', 'electrolyte_code', 'coin_cell_code',
'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes',
'target_mass_ratio', 'real_mass_ratio'
])
#立刻写入磁盘
csvfile.flush()
#开始追加电池信息
with open(self.csv_export_file, 'a', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile)
# ========== 提取配方信息 ==========
formulation_order_name = ""
prep_bottle_barcode = ""
vial_bottle_barcodes = ""
target_ratio_str = ""
real_ratio_str = ""
# 从 self._formulations_list 获取配方信息
if hasattr(self, '_formulations_list') and self._formulations_list:
# ✅ 新方案:根据电池编号和每瓶电池数计算当前瓶号
# 例如elec_use_num=2时电池1-2用瓶0电池3-4用瓶1
if hasattr(self, '_elec_use_num') and self._elec_use_num:
# ⚠️ 确保转换为整数(防御性编程)
elec_use_num_int = int(self._elec_use_num) if self._elec_use_num else 1
if elec_use_num_int > 0:
current_bottle_index = (data_battery_number - 1) // elec_use_num_int
else:
current_bottle_index = 0
logger.debug(
f"[CSV写入] 电池 {data_battery_number}: 计算瓶号索引={current_bottle_index} "
f"(每瓶{self._elec_use_num}颗电池)"
)
else:
# 降级方案:尝试从二维码解析(仅当参数未设置时)
current_bottle_index = int(data_electrolyte_code.split('-')[-1]) if '-' in str(data_electrolyte_code) else 0
logger.debug(
f"[CSV写入] 电池 {data_battery_number}: 从二维码解析瓶号索引={current_bottle_index}"
)
# 从配方列表中获取对应配方
if 0 <= current_bottle_index < len(self._formulations_list):
formulation = self._formulations_list[current_bottle_index]
formulation_order_name = formulation.get("orderName", "")
prep_bottle_barcode = formulation.get("prep_bottle_barcode", "")
vial_bottle_barcodes = formulation.get("vial_bottle_barcodes", "")
real_ratio = formulation.get("real_mass_ratio", {})
target_ratio = formulation.get("target_mass_ratio", {})
# 将配方比例转为JSON字符串
import json
target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else ""
real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else ""
logger.info(
f"[CSV写入] 电池 {data_battery_number}: 使用配方[{current_bottle_index}] "
f"orderName={formulation_order_name}, 配液瓶={prep_bottle_barcode}, 分液瓶={vial_bottle_barcodes}"
)
else:
logger.warning(
f"[CSV写入] 电池 {data_battery_number}: 瓶号索引 {current_bottle_index} "
f"超出配方列表范围 (共{len(self._formulations_list)}个配方)"
)
else:
logger.debug(f"[CSV写入] 电池 {data_battery_number}: 未找到配方信息数据")
writer.writerow([
timestamp, data_open_circuit_voltage, data_pole_weight,
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
data_coin_num, data_electrolyte_code, data_coin_cell_code
data_coin_type, data_electrolyte_code, data_coin_cell_code,
formulation_order_name, prep_bottle_barcode, vial_bottle_barcodes,
target_ratio_str, real_ratio_str
])
#立刻写入磁盘
csvfile.flush()
@@ -1667,8 +1959,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
file_path: str = "/Users/sml/work"
) -> Dict[str, Any]:
"""
简化版电池组装函数,整合了原 qiming_coin_cell_code 的参数设置和双滴模式
此函数是 func_allpack_cmd 的增强版本,自动处理以下配置:
- 负极片和隔膜的盘数及矩阵点位
- 枪头盒矩阵点位
@@ -1922,7 +2213,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
def fun_wuliao_test(self) -> bool:
#找到data_init中构建的2个物料盘
liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
test_battery_plate = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
for i in range(16):
battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2)
battery._unilabos_state = {
@@ -1932,7 +2223,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
"electrolyte_volume": 20.0,
"electrolyte_name": f"DP{i}"
}
liaopan3.children[i].assign_child_resource(battery, location=None)
test_battery_plate.children[i].assign_child_resource(battery, location=None)
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
@@ -1975,7 +2266,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
data_assembly_time = self.data_assembly_time
data_assembly_pressure = self.data_assembly_pressure
data_electrolyte_volume = self.data_electrolyte_volume
data_coin_num = self.data_coin_num
data_coin_type = self.data_coin_type # 电池类型7或8种物料
data_electrolyte_code = self.data_electrolyte_code
data_coin_cell_code = self.data_coin_cell_code
# 电解液瓶位置
@@ -2089,7 +2380,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
writer.writerow([
timestamp, data_open_circuit_voltage, data_pole_weight,
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
data_coin_num, data_electrolyte_code, data_coin_cell_code
data_coin_type, data_electrolyte_code, data_coin_cell_code # ✅ 已修正
])
#立刻写入磁盘
csvfile.flush()
@@ -2140,7 +2431,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
if __name__ == "__main__":
# 简单测试
workstation = CoinCellAssemblyWorkstation(deck=CoincellDeck(setup=True, name="coin_cell_deck"))
workstation = CoinCellAssemblyWorkstation(deck=yihua_coin_cell_deck(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)}")

View File

@@ -1,4 +1,4 @@
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
@@ -29,7 +29,9 @@ REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
REG_DATA_COIN_TYPE,INT16,,,,hold_register,10018,data_coin_type
REG_DATA_CURRENT_ASSEMBLING_COUNT,INT16,,,,hold_register,10072,data_current_assembling_count
REG_DATA_CURRENT_COMPLETED_COUNT,INT16,,,,hold_register,10074,data_current_completed_count
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
@@ -69,65 +71,75 @@ REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警X、Y、Z参数超限制
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警1#吸嘴取料失败
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警2#吸嘴取料失败
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警3#吸嘴取料失败
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警4#吸嘴取料失败
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,??100-????
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,??101-??
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,??111-?????
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,??112-????????
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,??160-??????
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,??161-?????
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,??162-?????
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,??163-?????
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,??164-????
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,??165-?????
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,??166-????
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,??167-????
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,??168-?????
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,??169-??????
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,??201-???01??
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,??202-???02??
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,??203-???03??
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,??204-???04??
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,??205-???05??
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,??206-???06??
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,??207-???07??
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,??208-???08??
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,??209-???09??
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,??210-???10??
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,??211-???11??
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,??212-???12??
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,??213-???13??
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,??214-???14??
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,??250-??????
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,??251-???????
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,??252-?????
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,??256-????
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,??262-RB?????????
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,??263-RB???X?Y?Z?????
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,??264-RB???????????
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,??265-RB???1#??????
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,??266-RB???2#??????
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,??267-RB???3#??????
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,??268-RB???4#??????
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,??269-RB?????????
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,??280-RB????
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,??290-????????
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,??291-????NG??
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,??292-???????
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,??310-???????????
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,??311-???????????
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,??312-???????????
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,??313-???????????
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,??340-????????????
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,??342-????????????
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,??344-??????????
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,??350-??????????
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,??352-??????????
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,??354-???????????
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,??356-???????????
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,??360-??????????
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,??362-???????????
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,??364-???????????
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,??366-?????????
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,??370-??????????
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,??151-??????????
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,??152-?????????
REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,520,10mm??????????R?
REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,522,12mm??????????R?
REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,524,16mm??????????R?
REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT,FLOAT32,,,,hold_register,526,?????????R?
REG_DATA_POSITIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,528,??????????R?
REG_DATA_FLAT_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,530,?????????R?
REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,532,??????????R?
REG_DATA_SPRING_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,534,?????????R?
REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY,FLOAT32,,,,hold_register,536,????????????R?
REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY,FLOAT32,,,,hold_register,538,????NG?????????R?
1 Name DataType InitValue Comment Attribute DeviceType Address
2 COIL_SYS_START_CMD BOOL coil 8010
3 COIL_SYS_STOP_CMD BOOL coil 8020
4 COIL_SYS_RESET_CMD BOOL coil 8030
29 REG_DATA_ASSEMBLY_PER_TIME FLOAT32 hold_register 10012 data_assembly_time
30 REG_DATA_ASSEMBLY_PRESSURE INT16 hold_register 10014 data_assembly_pressure
31 REG_DATA_ELECTROLYTE_VOLUME INT16 hold_register 10016 data_electrolyte_volume
32 REG_DATA_COIN_NUM REG_DATA_COIN_TYPE INT16 hold_register 10018 data_coin_num data_coin_type
33 REG_DATA_CURRENT_ASSEMBLING_COUNT INT16 hold_register 10072 data_current_assembling_count
34 REG_DATA_CURRENT_COMPLETED_COUNT INT16 hold_register 10074 data_current_completed_count
35 REG_DATA_ELECTROLYTE_CODE STRING hold_register 10020 data_electrolyte_code()
36 REG_DATA_COIN_CELL_CODE STRING hold_register 10030 data_coin_cell_code()
37 REG_DATA_STACK_VISON_CODE STRING hold_register 12004 data_stack_vision_code()
71 COIL_MATERIAL_SEARCH_DIALOG_APPEAR BOOL coil 6470
72 COIL_MATERIAL_SEARCH_CONFIRM_YES BOOL coil 6480
73 COIL_MATERIAL_SEARCH_CONFIRM_NO BOOL coil 6490
74 COIL_ALARM_100_SYSTEM_ERROR BOOL coil 1000 异常100-系统异常 ??100-????
75 COIL_ALARM_101_EMERGENCY_STOP BOOL coil 1010 异常101-急停 ??101-??
76 COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP BOOL coil 1110 异常111-手套箱急停 ??111-?????
77 COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED BOOL coil 1120 异常112-手套箱内光栅遮挡 ??112-????????
78 COIL_ALARM_160_PIPETTE_TIP_SHORTAGE BOOL coil 1600 异常160-移液枪头缺料 ??160-??????
79 COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE BOOL coil 1610 异常161-正极壳缺料 ??161-?????
80 COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE BOOL coil 1620 异常162-铝箔垫缺料 ??162-?????
81 COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE BOOL coil 1630 异常163-正极片缺料 ??163-?????
82 COIL_ALARM_164_SEPARATOR_SHORTAGE BOOL coil 1640 异常164-隔膜缺料 ??164-????
83 COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE BOOL coil 1650 异常165-负极片缺料 ??165-?????
84 COIL_ALARM_166_FLAT_WASHER_SHORTAGE BOOL coil 1660 异常166-平垫缺料 ??166-????
85 COIL_ALARM_167_SPRING_WASHER_SHORTAGE BOOL coil 1670 异常167-弹垫缺料 ??167-????
86 COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE BOOL coil 1680 异常168-负极壳缺料 ??168-?????
87 COIL_ALARM_169_FINISHED_BATTERY_FULL BOOL coil 1690 异常169-成品电池满料 ??169-??????
88 COIL_ALARM_201_SERVO_AXIS_01_ERROR BOOL coil 2010 异常201-伺服轴01异常 ??201-???01??
89 COIL_ALARM_202_SERVO_AXIS_02_ERROR BOOL coil 2020 异常202-伺服轴02异常 ??202-???02??
90 COIL_ALARM_203_SERVO_AXIS_03_ERROR BOOL coil 2030 异常203-伺服轴03异常 ??203-???03??
91 COIL_ALARM_204_SERVO_AXIS_04_ERROR BOOL coil 2040 异常204-伺服轴04异常 ??204-???04??
92 COIL_ALARM_205_SERVO_AXIS_05_ERROR BOOL coil 2050 异常205-伺服轴05异常 ??205-???05??
93 COIL_ALARM_206_SERVO_AXIS_06_ERROR BOOL coil 2060 异常206-伺服轴06异常 ??206-???06??
94 COIL_ALARM_207_SERVO_AXIS_07_ERROR BOOL coil 2070 异常207-伺服轴07异常 ??207-???07??
95 COIL_ALARM_208_SERVO_AXIS_08_ERROR BOOL coil 2080 异常208-伺服轴08异常 ??208-???08??
96 COIL_ALARM_209_SERVO_AXIS_09_ERROR BOOL coil 2090 异常209-伺服轴09异常 ??209-???09??
97 COIL_ALARM_210_SERVO_AXIS_10_ERROR BOOL coil 2100 异常210-伺服轴10异常 ??210-???10??
98 COIL_ALARM_211_SERVO_AXIS_11_ERROR BOOL coil 2110 异常211-伺服轴11异常 ??211-???11??
99 COIL_ALARM_212_SERVO_AXIS_12_ERROR BOOL coil 2120 异常212-伺服轴12异常 ??212-???12??
100 COIL_ALARM_213_SERVO_AXIS_13_ERROR BOOL coil 2130 异常213-伺服轴13异常 ??213-???13??
101 COIL_ALARM_214_SERVO_AXIS_14_ERROR BOOL coil 2140 异常214-伺服轴14异常 ??214-???14??
102 COIL_ALARM_250_OTHER_COMPONENT_ERROR BOOL coil 2500 异常250-其他元件异常 ??250-??????
103 COIL_ALARM_251_PIPETTE_COMM_ERROR BOOL coil 2510 异常251-移液枪通讯异常 ??251-???????
104 COIL_ALARM_252_PIPETTE_ALARM BOOL coil 2520 异常252-移液枪报警 ??252-?????
105 COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR BOOL coil 2560 异常256-电爪异常 ??256-????
106 COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR BOOL coil 2620 异常262-RB报警:未知点位错误 ??262-RB?????????
107 COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR BOOL coil 2630 异常263-RB报警:X、Y、Z参数超限制 ??263-RB???X?Y?Z?????
108 COIL_ALARM_264_RB_VISION_PARAM_ERROR BOOL coil 2640 异常264-RB报警:视觉参数误差过大 ??264-RB???????????
109 COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL BOOL coil 2650 异常265-RB报警:1#吸嘴取料失败 ??265-RB???1#??????
110 COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL BOOL coil 2660 异常266-RB报警:2#吸嘴取料失败 ??266-RB???2#??????
111 COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL BOOL coil 2670 异常267-RB报警:3#吸嘴取料失败 ??267-RB???3#??????
112 COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL BOOL coil 2680 异常268-RB报警:4#吸嘴取料失败 ??268-RB???4#??????
113 COIL_ALARM_269_RB_TRAY_PICK_FAIL BOOL coil 2690 异常269-RB报警:取物料盘失败 ??269-RB?????????
114 COIL_ALARM_280_RB_COLLISION_ERROR BOOL coil 2800 异常280-RB碰撞异常 ??280-RB????
115 COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR BOOL coil 2900 异常290-视觉系统通讯异常 ??290-????????
116 COIL_ALARM_291_VISION_ALIGNMENT_NG BOOL coil 2910 异常291-视觉对位NG异常 ??291-????NG??
117 COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR BOOL coil 2920 异常292-扫码枪通讯异常 ??292-???????
118 COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR BOOL coil 3100 异常310-开电移载吸嘴吸真空异常 ??310-???????????
119 COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR BOOL coil 3110 异常311-开电移载吸嘴破真空异常 ??311-???????????
120 COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR BOOL coil 3120 异常312-称重移载吸嘴吸真空异常 ??312-???????????
121 COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR BOOL coil 3130 异常313-称重移载吸嘴破真空异常 ??313-???????????
122 COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR BOOL coil 3400 异常340-开路电压吸嘴移载气缸异常 ??340-????????????
123 COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR BOOL coil 3420 异常342-开路电压吸嘴升降气缸异常 ??342-????????????
124 COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR BOOL coil 3440 异常344-开路电压旋压气缸异常 ??344-??????????
125 COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR BOOL coil 3500 异常350-称重吸嘴移载气缸异常 ??350-??????????
126 COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR BOOL coil 3520 异常352-称重吸嘴升降气缸异常 ??352-??????????
127 COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR BOOL coil 3540 异常354-清洗无尘布移载气缸异常 ??354-???????????
128 COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR BOOL coil 3560 异常356-清洗无尘布压紧气缸异常 ??356-???????????
129 COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR BOOL coil 3600 异常360-电解液瓶定位气缸异常 ??360-??????????
130 COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR BOOL coil 3620 异常362-移液枪头盒定位气缸异常 ??362-???????????
131 COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR BOOL coil 3640 异常364-试剂瓶夹爪升降气缸异常 ??364-???????????
132 COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR BOOL coil 3660 异常366-试剂瓶夹爪气缸异常 ??366-?????????
133 COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR BOOL coil 3700 异常370-压制模块吹气气缸异常 ??370-??????????
134 COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR BOOL coil 1510 异常151-电解液瓶定位在籍异常 ??151-??????????
135 COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR BOOL coil 1520 异常152-电解液瓶盖在籍异常 ??152-?????????
136 REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT FLOAT32 hold_register 520 10mm??????????R?
137 REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT FLOAT32 hold_register 522 12mm??????????R?
138 REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT FLOAT32 hold_register 524 16mm??????????R?
139 REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT FLOAT32 hold_register 526 ?????????R?
140 REG_DATA_POSITIVE_SHELL_REMAINING_COUNT FLOAT32 hold_register 528 ??????????R?
141 REG_DATA_FLAT_WASHER_REMAINING_COUNT FLOAT32 hold_register 530 ?????????R?
142 REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT FLOAT32 hold_register 532 ??????????R?
143 REG_DATA_SPRING_WASHER_REMAINING_COUNT FLOAT32 hold_register 534 ?????????R?
144 REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY FLOAT32 hold_register 536 ????????????R?
145 REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY FLOAT32 hold_register 538 ????NG?????????R?

View File

@@ -0,0 +1,88 @@
# 物料系统标准化重构方案
根据开发者的反馈本方案旨在遵循“标准化而非绕过”的原则对资源类Deck、Carrier、Magazine进行重构。核心目标是将物理结构的初始化与物料/极片的初始填充逻辑解耦,彻底解决反序列化过程中的初始化冲突。
## 拟议变更
### [参考] PRCXI9300 标准化模式
#### [参考文件] [prcxi.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/prcxi.py)
* **PRCXI9300Deck**: 演示了如何在 `serialize` 中导出 `sites` 元数据,以及如何在 `assign_child_resource` 中实现稳健的槽位匹配(支持按名称、坐标或索引匹配)。
* **PRCXI9300Container**: 演示了标准的 `load_state``serialize_state` 模式,确保业务状态(如 `Material` UUID能正确往返序列化。
### [组件] 台面 (Decks)
#### [修改] [decks.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/bioyond/decks.py)
*`BIOYOND_YB_Deck` 重命名为 **`BioyondElectrolyteDeck`**,对应工厂函数 `YB_Deck()` 重命名为 **`bioyond_electrolyte_deck()`**。
* `BIOYOND_PolymerReactionStation_Deck``BIOYOND_PolymerPreparationStation_Deck` **保持不变**
* 以上三个 Deck 的 `__init__` 中均移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
#### [修改 + 重命名] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) → `yihua_coin_cell_materials.py`
*`CoincellDeck` 重命名为 **`YihuaCoinCellDeck`**,对应工厂函数 `YH_Deck()` 重命名为 **`yihua_coin_cell_deck()`**。
*`YihuaCoinCellDeck.__init__` 中移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
### [组件] 容器类与弹夹 (Itemized Carriers & Magazines)
#### [修改] [magazine.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/battery/magazine.py)
* 重构 `magazine_factory`:将创建 `MagazineHolder` 几何结构(空槽位)的过程与填充 `ElectrodeSheet` 物料的过程分离。
* 确保 `MagazineHolder``Magazine``__init__` 过程中不主动创建任何内容物。
#### [修改] [warehouse.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/warehouse.py)
* 确保 `WareHouse` 类和 `warehouse_factory` 遵循相同模式:先初始化几何结构,内容物另行处理。
#### [修改] [itemized_carrier.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/itemized_carrier.py)
* 移除之前添加的 `idx is None` 兜底补丁。
* 修复命名规范,确保 `assign_child_resource` 在反序列化时能准确匹配资源。
### [组件] 状态兼容性 (State Compatibility)
#### [修改] [resource_tracker.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/resource_tracker.py)
*`to_plr_resources` 方法中调用 `load_all_state` 之前,预处理 `all_states` 字典。
* 对于 `Container` 类型的资源,如果其状态中缺少 `liquid_history``pending_liquids` 等 PLR 新版本要求的键,则填充默认值(如空列表/字典),防止反序列化中断。
### [组件] 料盘 (Material Plates)
#### [修改] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py)
* 重构 `MaterialPlate`:不在 `__init__` 中直接调用 `create_ordered_items_2d`
* 重构 `YIHUA_Electrolyte_12VialCarrier`:将其修改为标准的基类定义或在工厂方法中彻底剥离内部 12 个 `YB_pei_ye_xiao_Bottle` 的强制初始化,以防反序列化冲突。
### [组件] 跨站转运与分液瓶板 (Vial Plate Transfer)
#### [修改] [bioyond_cell_workstation.py] & [YB_YH_materials.py]
* **分析**:目前的 `bioyond_cell_workstation.py` 在执行转移时,是用 `sites=["electrolyte_buffer"]` 试图把整块 `YB_Vial_5mL_Carrier` 板转移给目标。但由于实际工艺中,配液站将分液瓶板传往扣电工站后,是由扣电工站的机械臂**逐瓶抓取**并放入内部的 `bottle_rack_6x2`(电解液缓存位),用完后再放入 `bottle_rack_6x2_2`(废液位),因此配液站的这一次“跨工位资源树转移”在逻辑上存在偏差:目标槽位不应该是装单瓶的载体 `bottle_rack`
* **修复方案**
1. **目标端 (Yihua 侧)**
*`YB_YH_materials.py` 中为从配液站传过来的“分液瓶板”本身设置一个接驳专用的 `PlateSlot`(或者单纯直接移到 Deck 指定坐标)。这个位置负责真正在资源树层级上合法接收配液站传过来的完整 Board。
* 重构 `YIHUA_Electrolyte_12VialCarrier`:为了防止初始化反序列化冲突,取消内部在 `__init__` 中自动填充满 12 个 `YB_pei_ye_xiao_Bottle` 实例的逻辑。`bottle_rack_6x2``bottle_rack_6x2_2` 初始化时均应为空。
2. **转运端 (Bioyond 侧)**
* 修改 `bioyond_cell_workstation.py` 的资源树数字转运代码,将其转移目标对应到 Yihua 侧新设立的“分液瓶板接驳区域”资源,或者干脆只更新资源树坐标位置(使其脱离 Bioyond Deck 加入 Yihua Deck而不再强行挂载到一个无法容纳 Carrier 的 `bottle_rack_6x2` 内部。
### [组件] 依华扣电组装工站物料余量监控 (Material Monitoring)
#### [修改] 寄存器直读与前端集成
* **物理对象保留但虚化追踪**:原有的实体台面对象(如 `MaterialPlate``MagazineHolder` 各类型及其对应的洞位坐标)**仍然保留并使用**。保留它们是为了给机器臂提供基础的物理空间取放标定,以及作为前端页面的可视和可交互区块。
* **内部物料免追踪**:既然余量完全由寄存器接管,**我们将不再在这些弹夹或洞位内部显式生成、塞入和追踪每一个具体的极片或外壳对象 (如 `ElectrodeSheet` 等)**。这恰好与我们的重构主旨(不主动在 `__init__` 建子物料以避开反序列化冲突)完美结合,进一步极大地减轻了后台资源树对象的复杂度。
* **监控方式变更**:放弃现有的物料余量方式,直接读取依华扣电组装工站开放的寄存器地址以获取准确余量。
* **前端界面集成**:在前端界面点击负极壳、弹垫片等弹夹的 data view 时,直接读取并显示寄存器中的各自余量。
* **新增寄存器映射** (参考 `coin_cell_assembly_b.csv`)
* `10mm正极片剩余物料数量R``read hold_register 520` (REAL)
* `12mm正极片剩余物料数量R``read hold_register 522` (REAL)
* `16mm正极片剩余物料数量R``read hold_register 524` (REAL)
* `铝箔剩余物料数量R``read hold_register 526` (REAL)
* `正极壳剩余物料数量R``read hold_register 528` (REAL)
* `平垫剩余物料数量R``read hold_register 530` (REAL)
* `负极壳剩余物料数量R``read hold_register 532` (REAL)
* `弹垫剩余物料数量R``read hold_register 534` (REAL)
* `成品电池剩余可容纳数量R``read hold_register 536` (REAL)
* `成品电池NG槽剩余可容纳数量R``read hold_register 538` (REAL)
### [配置] JSON 配置文件 (Configuration Files)
#### [修改] 资源类型名称更新
* 更新以下配置文件,将其中的 `BIOYOND_YB_Deck` 替换为新的类名 **`BioyondElectrolyteDeck`**,以及将 `coin_cell_deck` 替换为 **`YihuaCoinCellDeck`**
* `yibin_electrolyte_config.json`
* `yibin_coin_cell_only_config.json`
* `yibin_electrolyte_only_config.json`
## 验证计划
### 自动化测试
* 对重构后的类运行 `pylabrobot` 序列化/反序列化测试,确保状态能够完美恢复。
* 检查各工作站节点启动时是否仍存在 `ValueError: Resource '...' already assigned to deck` 报错。
* 检查 `resource_tracker` 中是否仍存在重复 UUID 报错。
### 手动验证
* 重启各工作站节点,验证资源树是否能根据数据库数据正确还还原。
* 验证“自动”与“手动”传输窗资源在台面上的分配是否正确。

View File

@@ -0,0 +1,388 @@
# 物料系统标准化重构方案 v2增强版
> **基于原始方案 (`implementation_plan.md`) 的补充与细化**。
> 本文档在原方案基础上:①增加当前代码现状核查结果;②明确各任务的执行顺序与文件级改动;③新增注意事项与回归测试命令。
---
## 0. 核心原则(保持不变)
"**物理几何结构初始化Deck / Carrier / Magazine 的 `__init__`)与物料内容物填充(`setup()` / `klasses` 参数)必须彻底解耦**",以消除 PLR 反序列化时的 `Resource already assigned to deck` 错误。
---
## 1. 当前代码现状核查2026-03-12
| 文件 | 计划要求 | 当前状态 | 是否完成 |
|---|---|---|---|
| `resources/bioyond/decks.py` | 重命名类;移除 `setup` 参数和 `deserialize` 补丁 | 仍是 `BIOYOND_YB_Deck``setup` 参数和 `deserialize` 均存在 | ❌ |
| `coin_cell_assembly/YB_YH_materials.py` | 重命名类;文件迁移;移除补丁 | 仍是 `CoincellDeck``setup` 参数和 `deserialize` 均存在 | ❌ |
| `resources/battery/magazine.py` | `magazine_factory` 不主动填充物料 | `MagazineHolder_6_Cathode` / `_6_Anode` / `_4_Cathode` 仍传 `klasses`,初始化时填满极片 | ❌ |
| `resources/battery/bottle_carriers.py` | `YIHUA_Electrolyte_12VialCarrier` 初始化时不填充瓶子 | 第 54-55 行仍循环填充 12 个 `YB_pei_ye_xiao_Bottle` | ❌ |
| `resources/itemized_carrier.py` | 移除 `idx is None` 兜底补丁 | 第 182-190 行仍保留该兜底逻辑 | ❌(待前置任务完成后移除) |
| `resources/resource_tracker.py` | `load_all_state` 前预填 `Container` 缺失键 | 第 616 行直接调用,无预处理 | ❌ |
| `bioyond_cell_workstation.py` | 修正跨站转运目标为合法接驳槽 | 第 1563 行仍 `sites=["electrolyte_buffer"]`,目标 UUID 为硬编码虚拟资源 | ❌ |
| `yibin_*.json` 配置文件 | 更新类名 | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌ |
| `registry/resources/bioyond/deck.yaml` | 更新类名(原计划未提及) | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌(**新增** |
---
## 2. 执行顺序(含依赖关系)
```
阶段 A底层资源类
A1. magazine.py — 移除 klasses 填充
A2. bottle_carriers.py — 移除瓶子填充
阶段 BDeck 层)
B1. decks.py — 移除 setup 参数和 deserialize 补丁;重命名
B2. YB_YH_materials.py → 重命名;移除 CoincellDeck 的 setup 参数和 deserialize 补丁
阶段 C状态兼容
C1. resource_tracker.py — 预填 Container 缺失键
C2. itemized_carrier.py — 移除 idx is None 兜底补丁B 阶段完成后)
阶段 D跨站转运修复
D1. YB_YH_materials.py 新增 vial_plate_dock接驳专用槽
D2. bioyond_cell_workstation.py 修正 transfer 目标
阶段 E配置与注册表
E1. yibin_*.json 更新类名
E2. registry/resources/bioyond/deck.yaml 更新类名
E3. coin_cell_assembly.py 更新导入路径(若文件重命名)
```
---
## 3. 分阶段详细说明
---
### 阶段 A — 底层资源类
#### A1. `unilabos/resources/battery/magazine.py`
**问题**`MagazineHolder_6_Cathode``MagazineHolder_6_Anode``MagazineHolder_4_Cathode` 在调用 `magazine_factory` 时传入 `klasses`,导致每次 `__init__` 就填满极片,反序列化时重复添加。
**修改**
- 将三个函数中的 `klasses=[...]` 改为 `klasses=None`(与 `MagazineHolder_6_Battery` 保持一致)。
- **理由**:物料余量已由寄存器管理(见阶段 F不需要在资源树中追踪每一个极片。
```python
# 修改前MagazineHolder_6_Cathode 举例)
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
# 修改后
klasses=None,
```
> **注意**`magazine_factory` 中 `klasses` 参数及循环体代码保留(仍可按需在非序列化场景使用),只是各具体工厂函数不再传入。
---
#### A2. `unilabos/resources/battery/bottle_carriers.py`
**问题**`YIHUA_Electrolyte_12VialCarrier` 第 54-55 行在工厂函数末尾循环填充 12 个瓶子。
**修改**:删除以下两行:
```python
# 删除
for i in range(12):
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
```
**理由**`bottle_rack_6x2``bottle_rack_6x2_2` 均应初始化为空,瓶子由 Bioyond 侧实际转运后再填入。
---
### 阶段 B — Deck 层重构
#### B1. `unilabos/resources/bioyond/decks.py`
**改动列表**
1. **重命名** `BIOYOND_YB_Deck``BioyondElectrolyteDeck`
2. **重命名** `YB_Deck()` 工厂函数 → `bioyond_electrolyte_deck()`
3. **移除** `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用
4. **删除** `deserialize` 方法重写(该临时补丁在 `setup` 参数移除后自然失效,继续保留反而掩盖问题)
5. `BIOYOND_PolymerReactionStation_Deck``BIOYOND_PolymerPreparationStation_Deck` 同步执行第 3、4 步
**重构后初始化模式**
```python
class BioyondElectrolyteDeck(Deck):
def __init__(self, name: str = "YB_Deck", ...):
super().__init__(name=name, ...)
# ❌ 不调用 self.setup()
# PLR 反序列化时只会调用 __init__然后从 children JSON 重建子资源
def setup(self) -> None:
# 完整的子资源初始化逻辑保留在这里,只由工厂函数调用
...
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
deck = BioyondElectrolyteDeck(name=name)
deck.setup() # ✅ 工厂函数负责填充
return deck
```
**同步修改**
- `bioyond_cell_workstation.py` 第 20 行:
```python
# 修改前
from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck
# 修改后
from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck
```
- 同文件第 2440 行:`BIOYOND_YB_Deck(setup=True)` → `bioyond_electrolyte_deck(name="YB_Deck")`
---
#### B2. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
**改动列表**
1. **重命名** `CoincellDeck` → `YihuaCoinCellDeck`
2. **重命名** `YH_Deck()` → `yihua_coin_cell_deck()`(可保留 `YH_Deck` 作为兼容别名,日后废弃)
3. **移除** `CoincellDeck.__init__` 中 `setup: bool = False` 参数及调用
4. **删除** `CoincellDeck.deserialize` 重写方法
5. `MaterialPlate.__init__` 中移除 `fill` 参数,始终不主动调用 `create_ordered_items_2d`(当前 `fill=False` 路径已正确,只需删除 `fill=True` 分支)
```python
# 修改前MaterialPlate.__init__ 片段)
if fill:
super().__init__(..., ordered_items=holes, ...)
else:
super().__init__(..., ordered_items=ordered_items, ...)
# 修改后(始终走 "不填充" 路径)
super().__init__(..., ordered_items=ordered_items, ...)
# holes 的创建代码整体移入独立工厂方法
```
**同步修改**
- `coin_cell_assembly.py` 第 20 行导入:
```python
# 修改前
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
# 修改后
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck
```
- 同文件第 2245 行:`CoincellDeck(setup=True, name="coin_cell_deck")` → `yihua_coin_cell_deck(name="coin_cell_deck")`
- 文件重命名(可选):`YB_YH_materials.py` → `yihua_coin_cell_materials.py`(若重命名,所有 import 路径需全局替换)
---
### 阶段 C — 状态兼容
#### C1. `unilabos/resources/resource_tracker.py`
**问题**:第 616 行直接调用 `plr_resource.load_all_state(all_states)`,若 `Container` 类资源的 `data` 字段缺少 `liquid_history` 或 `pending_liquids`PLR 新版本会抛出 `KeyError`。
**修改**:在第 616 行前插入预处理:
```python
# 在 load_all_state 调用前预填缺失键
from pylabrobot.resources.container import Container as PLRContainer
for res_name, state in all_states.items():
if state and isinstance(state, dict):
# Container 类型要求这两个键存在
state.setdefault("liquid_history", [])
state.setdefault("pending_liquids", {})
plr_resource.load_all_state(all_states)
```
---
#### C2. `unilabos/resources/itemized_carrier.py`
**前提**B1、B2 阶段完成Deck 类名与资源命名规范已对齐后再执行此步。
**修改**:删除第 182-190 行的兜底补丁:
```python
# 删除以下整个 if 块
if idx is None:
fallback_location = location if location is not None else Coordinate.zero()
super().assign_child_resource(resource, location=fallback_location, reassign=reassign)
return
```
**替代**:改为抛出带诊断信息的异常,便于后续问题排查:
```python
if idx is None:
raise ValueError(
f"[ItemizedCarrier] 无法为资源 '{resource.name}' 找到匹配的槽位。"
f"已知槽位:{list(self.child_locations.keys())}"
f"传入坐标:{location}"
)
```
---
### 阶段 D — 跨站转运修复
#### D1. `YB_YH_materials.py` — 新增分液瓶板接驳槽
在 `YihuaCoinCellDeck.setup()` 中,新增一个专用于接收 Bioyond 侧传来的完整分液瓶板的 `ResourceStack`(或 `PlateSlot`
```python
# 在 setup() 末尾追加
from pylabrobot.resources.resource_stack import ResourceStack
vial_plate_dock = ResourceStack(
name="electrolyte_buffer", # 保持与 bioyond_cell_workstation.py 的 sites 键一致
direction="z",
resources=[],
)
self.assign_child_resource(vial_plate_dock, Coordinate(x=1050.0, y=700.0, z=0))
```
> **说明**:槽位命名 `electrolyte_buffer` 与 `bioyond_cell_workstation.py` 现有的 `sites=["electrolyte_buffer"]` 对应减少改动量。如改名D2 需同步。
---
#### D2. `bioyond_cell_workstation.py` — 修正 transfer 目标
**问题**:第 1545-1552 行创建了一个 `size=1,1,1` 的虚拟 `ResourcePLR` 并硬编码 UUID这个对象在 YihuaCoinCellDeck 的资源树中不存在,导致转移后资源树状态混乱。
**修改**
```python
# 修改前:创建虚拟目标资源
target_resource_obj = ResourcePLR(name=target_location, size_x=1.0, ...)
target_resource_obj.unilabos_uuid = "550e8400-e29b-41d4-a716-446655440001" # 硬编码
# 修改后:通过 ROS2/设备注册表查询真实资源
# (需要从 target_device 的资源树中取出 electrolyte_buffer 的真实对象)
target_resource_obj = self._get_resource_from_device(
device_id=target_device,
resource_name=target_location
)
if target_resource_obj is None:
raise RuntimeError(
f"目标设备 {target_device} 中未找到资源 '{target_location}'"
f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位"
)
```
> **说明**`_get_resource_from_device` 需根据现有 ROS2 资源同步机制实现,或复用已有的 `get_plr_resource_by_name` 类似方法。
---
### 阶段 E — 配置与注册表
#### E1. `yibin_electrolyte_config.json` / `yibin_coin_cell_only_config.json` / `yibin_electrolyte_only_config.json`
全局替换以下字符串:
| 旧值 | 新值 |
|---|---|
| `BIOYOND_YB_Deck` | `BioyondElectrolyteDeck` |
| `unilabos.resources.bioyond.decks:BIOYOND_YB_Deck` | `unilabos.resources.bioyond.decks:BioyondElectrolyteDeck` |
| `CoincellDeck` | `YihuaCoinCellDeck` |
| `unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck` | 若文件已重命名:`unilabos.devices.workstation.coin_cell_assembly.yihua_coin_cell_materials:YihuaCoinCellDeck` |
---
#### E2. `unilabos/registry/resources/bioyond/deck.yaml`**原计划未覆盖,新增**
当前第 25 行和第 37 行仍使用旧类名,需同步更新:
```yaml
# 修改前
BIOYOND_YB_Deck:
...
CoincellDeck:
...
# 修改后
BioyondElectrolyteDeck:
...
YihuaCoinCellDeck:
...
```
---
### 阶段 F — 物料余量监控集成原计划第5节细化
**目标**:弃用资源树内极片对象计数,改为直读依华扣电工站寄存器。
#### F1. `coin_cell_assembly/coin_cell_assembly.py` — 新增寄存器读取方法
参考 `coin_cell_assembly_b.csv` 中的地址,封装读取工具方法:
```python
MATERIAL_REGISTER_MAP = {
"10mm正极片": (520, "REAL"),
"12mm正极片": (522, "REAL"),
"16mm正极片": (524, "REAL"),
"铝箔": (526, "REAL"),
"正极壳": (528, "REAL"),
"平垫": (530, "REAL"),
"负极壳": (532, "REAL"),
"弹垫": (534, "REAL"),
"成品容量": (536, "REAL"),
"成品NG容量": (538, "REAL"),
}
def get_material_remaining(self, material_name: str) -> float:
"""通过寄存器直读指定物料的剩余数量"""
if material_name not in MATERIAL_REGISTER_MAP:
raise KeyError(f"未知物料名称: {material_name}")
address, dtype = MATERIAL_REGISTER_MAP[material_name]
return self.read_hold_register(address, dtype) # 复用现有 Modbus 读取方法
```
#### F2. 前端 data view 集成
- 前端点击 `MagazineHolder` 类资源的 data view 时,调用后端 `get_material_remaining` 接口(而非读取 `children` 长度)。
- 具体接口路径和前端调用代码需与前端开发同步,本文档不作具体实现约定。
---
## 4. 验证计划(细化)
### 4.1 单元测试(自动化)
```bash
# 序列化/反序列化往返测试
python -m pytest unilabos/test/ -k "serial" -v
# 特别检查以下错误消失:
# - ValueError: Resource '...' already assigned to deck
# - KeyError: 'liquid_history'
# - 重复 UUID 报错
```
### 4.2 集成测试(手动)
按以下顺序逐步验证,确保每步正常后再进行下一步:
1. **单独启动 `BatteryStation` 节点**,检查 `CoincellDeck`(现 `YihuaCoinCellDeck`)能否从数据库状态正确还原,无 `already assigned` 报错。
2. **单独启动 `BioyondElectrolyte` 节点**,检查 `BioyondElectrolyteDeck` 反序列化正常。
3. **同时启动两个节点**,模拟执行一次分液→扣电的完整跨站转运,确认:
- `electrolyte_buffer` 槽位正确接收分液瓶板。
- `bottle_rack_6x2` 初始为空,不出现虚拟瓶子。
4. **重启两个节点**(模拟断电恢复),确认资源树从数据库还原后,`electrolyte_buffer` 中仍持有正确的分液瓶板对象。
5. **寄存器余量读取**:手动触发 `get_material_remaining("负极壳")`,确认返回值与设备显示一致。
---
## 5. 与原计划的差异对照
| 维度 | 原计划 | 本文档新增/修订 |
|---|---|---|
| 执行顺序 | 未排序 | 明确 A→B→C→D→E→F 的依赖顺序 |
| `itemized_carrier.py` | 移除兜底补丁 | 补充:替换为带诊断信息的异常,便于排查 |
| `bottle_carriers.py` | 提及 `YIHUA_Electrolyte_12VialCarrier` 需修改 | 明确:删除第 54-55 行的瓶子填充循环 |
| `MaterialPlate` | 提及移除 `fill` 参数 | 说明保留 `fill=False` 路径;整体删除 `fill=True` 分支 |
| `deck.yaml` | 未提及 | **新增**:该注册文件也需要同步更新类名 |
| `resource_tracker.py` | 简略描述 | 提供具体的 `setdefault` 预处理代码示例 |
| 跨站转运 | 描述了问题和方向 | 细化:新增 `electrolyte_buffer` 槽位的具体名称和坐标;修正 `transfer` 目标查找方式 |
| 验证计划 | 简述目标 | 提供具体测试命令和逐步手动验证流程 |

View File

@@ -139,6 +139,7 @@ def scan_directory(
executor: ThreadPoolExecutor = None,
exclude_files: Optional[set] = None,
cache: Optional[Dict[str, Any]] = None,
include_files: Optional[List[Union[str, Path]]] = None,
) -> Dict[str, Any]:
"""
Recursively scan .py files under *root_dir* for @device and @resource
@@ -164,6 +165,7 @@ def scan_directory(
exclude_files: 要排除的文件名集合 (如 {"lab_resources.py"})
cache: Mutable cache dict (``load_scan_cache()`` result). Hits are read
from here; misses are written back so the caller can persist later.
include_files: 指定扫描的文件列表,提供时跳过目录递归收集,直接扫描这些文件。
"""
if executor is None:
raise ValueError("executor is required and must not be None")
@@ -175,7 +177,10 @@ def scan_directory(
python_path = Path(python_path).resolve()
# --- Collect files (depth/count limited) ---
py_files = _collect_py_files(root_dir, max_depth=max_depth, max_files=max_files, exclude_files=exclude_files)
if include_files is not None:
py_files = [Path(f).resolve() for f in include_files if Path(f).resolve().exists()]
else:
py_files = _collect_py_files(root_dir, max_depth=max_depth, max_files=max_files, exclude_files=exclude_files)
cache_files: Dict[str, Any] = cache.get("files", {}) if cache else {}
@@ -674,14 +679,17 @@ def _resolve_name(name: str, import_map: Dict[str, str]) -> str:
return name
_DECORATOR_ENUM_CLASSES = frozenset({"Side", "DataSource", "NodeType"})
def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
"""
Resolve an attribute access like Side.NORTH or DataSource.HANDLE.
Returns a string like "NORTH" for enum values, or
"module.path:Class.attr" for imported references.
对于来自 ``unilabos.registry.decorators`` 的枚举类 (Side / DataSource / NodeType)
直接返回枚举成员名 (如 ``"NORTH"`` / ``"HANDLE"`` / ``"MANUAL_CONFIRM"``)
省去消费端二次 rsplit 解析。其它 import 仍返回完整模块路径。
"""
# Get the full dotted path
parts = []
current = node
while isinstance(current, ast.Attribute):
@@ -691,21 +699,20 @@ def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
parts.append(current.id)
parts.reverse()
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"]
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"] or ["NodeType", "MANUAL_CONFIRM"]
if len(parts) >= 2:
base = parts[0]
attr = ".".join(parts[1:])
# If the base is an imported name, resolve it
if base in _DECORATOR_ENUM_CLASSES:
source = import_map.get(base, "")
if not source or _REGISTRY_DECORATOR_MODULE in source:
return parts[-1]
if base in import_map:
return f"{import_map[base]}.{attr}"
# For known enum-like patterns, return just the value
# e.g. Side.NORTH -> "NORTH"
if base in ("Side", "DataSource"):
return parts[-1]
return ".".join(parts)

View File

@@ -8,7 +8,7 @@ Usage:
device, action, resource,
InputHandle, OutputHandle,
ActionInputHandle, ActionOutputHandle,
HardwareInterface, Side, DataSource,
HardwareInterface, Side, DataSource, NodeType,
)
@device(
@@ -73,6 +73,13 @@ class DataSource(str, Enum):
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
class NodeType(str, Enum):
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
ILAB = "ILab"
MANUAL_CONFIRM = "manual_confirm"
# ---------------------------------------------------------------------------
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
# ---------------------------------------------------------------------------
@@ -335,6 +342,7 @@ def action(
description: str = "",
auto_prefix: bool = False,
parent: bool = False,
node_type: Optional["NodeType"] = None,
):
"""
动作方法装饰器
@@ -365,6 +373,8 @@ def action(
description: 动作描述
auto_prefix: 若为 True动作名使用 auto-{method_name} 形式(与无 @action 时一致)
parent: 若为 True当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
不填写时不写入注册表。
"""
def decorator(func: F) -> F:
@@ -389,6 +399,8 @@ def action(
"auto_prefix": auto_prefix,
"parent": parent,
}
if node_type is not None:
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
@@ -515,6 +527,38 @@ def clear_registry():
_registered_resources.clear()
# ---------------------------------------------------------------------------
# 枚举值归一化
# ---------------------------------------------------------------------------
def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]:
"""将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。
适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。
处理以下格式:
- "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm"
- "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm"
- "HANDLE" → DataSource["HANDLE"].value = "handle"
- "NORTH" → Side["NORTH"].value = "NORTH"
- 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找
"""
if not raw:
return None
raw_str = str(raw)
if "." in raw_str:
raw_str = raw_str.rsplit(".", 1)[-1]
try:
return enum_cls[raw_str].value
except KeyError:
pass
try:
return enum_cls(raw_str).value
except ValueError:
return raw_str
# ---------------------------------------------------------------------------
# topic_config / not_action / always_free 装饰器
# ---------------------------------------------------------------------------

File diff suppressed because it is too large Load Diff

View File

@@ -64,59 +64,12 @@ coincellassemblyworkstation_device:
properties: {}
required: []
type: object
result:
type: boolean
result: {}
required:
- goal
title: fun_wuliao_test参数
type: object
type: UniLabJsonCommand
auto-func_allpack_cmd:
feedback: {}
goal: {}
goal_default:
assembly_pressure: 4200
assembly_type: 7
elec_num: null
elec_use_num: null
elec_vol: 50
file_path: /Users/sml/work
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
assembly_pressure:
default: 4200
type: integer
assembly_type:
default: 7
type: integer
elec_num:
type: string
elec_use_num:
type: string
elec_vol:
default: 50
type: integer
file_path:
default: /Users/sml/work
type: string
required:
- elec_num
- elec_use_num
type: object
result:
type: object
required:
- goal
title: func_allpack_cmd参数
type: object
type: UniLabJsonCommand
auto-func_allpack_cmd_simp:
feedback: {}
goal: {}
@@ -149,7 +102,7 @@ coincellassemblyworkstation_device:
goal:
properties:
assembly_pressure:
default: 4200
default: 3200
description: 电池压制力(N)
type: integer
assembly_type:
@@ -165,7 +118,7 @@ coincellassemblyworkstation_device:
description: 是否启用压力模式
type: boolean
dual_drop_first_volume:
default: 25
default: 0
description: 二次滴液第一次排液体积(μL)
type: integer
dual_drop_mode:
@@ -184,6 +137,7 @@ coincellassemblyworkstation_device:
description: 电解液瓶数
type: string
elec_use_num:
default: 5
description: 每瓶电解液组装电池数
type: string
elec_vol:
@@ -191,7 +145,7 @@ coincellassemblyworkstation_device:
description: 电解液吸液量(μL)
type: integer
file_path:
default: /Users/sml/work
default: D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly
description: 实验记录保存路径
type: string
fujipian_juzhendianwei:
@@ -222,8 +176,7 @@ coincellassemblyworkstation_device:
- elec_num
- elec_use_num
type: object
result:
type: object
result: {}
required:
- goal
title: func_allpack_cmd_simp参数
@@ -312,8 +265,7 @@ coincellassemblyworkstation_device:
type: boolean
required: []
type: object
result:
type: boolean
result: {}
required:
- goal
title: func_pack_device_init_auto_start_combined参数
@@ -355,8 +307,7 @@ coincellassemblyworkstation_device:
properties: {}
required: []
type: object
result:
type: boolean
result: {}
required:
- goal
title: func_pack_device_stop参数
@@ -381,8 +332,7 @@ coincellassemblyworkstation_device:
type: string
required: []
type: object
result:
type: boolean
result: {}
required:
- goal
title: func_pack_get_msg_cmd参数
@@ -396,12 +346,10 @@ coincellassemblyworkstation_device:
handles:
input:
- data_key: bottle_num
data_source: workflow
data_source: handle
data_type: integer
handler_key: bottle_count
io_type: source
label: 配液瓶数
required: true
placeholder_keys: {}
result: {}
schema:
@@ -436,8 +384,7 @@ coincellassemblyworkstation_device:
properties: {}
required: []
type: object
result:
type: boolean
result: {}
required:
- goal
title: func_pack_send_finished_cmd参数
@@ -474,8 +421,7 @@ coincellassemblyworkstation_device:
- assembly_type
- assembly_pressure
type: object
result:
type: boolean
result: {}
required:
- goal
title: func_pack_send_msg_cmd参数
@@ -531,12 +477,15 @@ coincellassemblyworkstation_device:
handles:
input:
- data_key: elec_num
data_source: workflow
data_source: handle
data_type: integer
handler_key: bottle_count
io_type: source
label: 配液瓶数
required: true
- data_key: formulations
data_source: handle
data_type: array
handler_key: formulations_input
label: 配方信息列表
placeholder_keys: {}
result: {}
schema:
@@ -619,8 +568,7 @@ coincellassemblyworkstation_device:
- elec_num
- elec_use_num
type: object
result:
type: object
result: {}
required:
- goal
title: func_sendbottle_allpack_multi参数
@@ -672,6 +620,31 @@ coincellassemblyworkstation_device:
title: modify_deck_name参数
type: object
type: UniLabJsonCommand
auto-post_init:
feedback: {}
goal: {}
goal_default:
ros_node: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
ros_node:
type: object
required:
- ros_node
type: object
result: {}
required:
- goal
title: post_init参数
type: object
type: UniLabJsonCommand
auto-qiming_coin_cell_code:
feedback: {}
goal: {}
@@ -719,8 +692,7 @@ coincellassemblyworkstation_device:
required:
- fujipian_panshu
type: object
result:
type: boolean
result: {}
required:
- goal
title: qiming_coin_cell_code参数
@@ -728,6 +700,10 @@ coincellassemblyworkstation_device:
type: UniLabJsonCommand
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
status_types:
data_10mm_positive_plate_remaining: float
data_12mm_positive_plate_remaining: float
data_16mm_positive_plate_remaining: float
data_aluminum_foil_remaining: float
data_assembly_coin_cell_num: int
data_assembly_pressure: int
data_assembly_time: float
@@ -735,14 +711,22 @@ coincellassemblyworkstation_device:
data_axis_y_pos: float
data_axis_z_pos: float
data_coin_cell_code: str
data_coin_num: int
data_coin_type: int
data_current_assembling_count: int
data_current_completed_count: int
data_electrolyte_code: str
data_electrolyte_volume: int
data_finished_battery_ng_remaining_capacity: float
data_finished_battery_remaining_capacity: float
data_flat_washer_remaining: float
data_glove_box_o2_content: float
data_glove_box_pressure: float
data_glove_box_water_content: float
data_negative_shell_remaining: float
data_open_circuit_voltage: float
data_pole_weight: float
data_positive_shell_remaining: float
data_spring_washer_remaining: float
request_rec_msg_status: bool
request_send_msg_status: bool
sys_mode: str
@@ -772,6 +756,14 @@ coincellassemblyworkstation_device:
type: object
data:
properties:
data_10mm_positive_plate_remaining:
type: number
data_12mm_positive_plate_remaining:
type: number
data_16mm_positive_plate_remaining:
type: number
data_aluminum_foil_remaining:
type: number
data_assembly_coin_cell_num:
type: integer
data_assembly_pressure:
@@ -786,22 +778,38 @@ coincellassemblyworkstation_device:
type: number
data_coin_cell_code:
type: string
data_coin_num:
data_coin_type:
type: integer
data_current_assembling_count:
type: integer
data_current_completed_count:
type: integer
data_electrolyte_code:
type: string
data_electrolyte_volume:
type: integer
data_finished_battery_ng_remaining_capacity:
type: number
data_finished_battery_remaining_capacity:
type: number
data_flat_washer_remaining:
type: number
data_glove_box_o2_content:
type: number
data_glove_box_pressure:
type: number
data_glove_box_water_content:
type: number
data_negative_shell_remaining:
type: number
data_open_circuit_voltage:
type: number
data_pole_weight:
type: number
data_positive_shell_remaining:
type: number
data_spring_washer_remaining:
type: number
request_rec_msg_status:
type: boolean
request_send_msg_status:
@@ -811,24 +819,36 @@ coincellassemblyworkstation_device:
sys_status:
type: string
required:
- sys_status
- sys_mode
- request_rec_msg_status
- request_send_msg_status
- data_assembly_coin_cell_num
- data_assembly_pressure
- data_assembly_time
- data_open_circuit_voltage
- data_axis_x_pos
- data_axis_y_pos
- data_axis_z_pos
- data_coin_cell_code
- data_coin_num
- data_electrolyte_code
- data_electrolyte_volume
- data_glove_box_o2_content
- data_glove_box_pressure
- data_glove_box_water_content
- data_open_circuit_voltage
- data_pole_weight
- request_rec_msg_status
- request_send_msg_status
- sys_mode
- sys_status
- data_assembly_pressure
- data_electrolyte_volume
- data_coin_type
- data_current_assembling_count
- data_current_completed_count
- data_coin_cell_code
- data_electrolyte_code
- data_glove_box_pressure
- data_glove_box_o2_content
- data_glove_box_water_content
- data_10mm_positive_plate_remaining
- data_12mm_positive_plate_remaining
- data_16mm_positive_plate_remaining
- data_aluminum_foil_remaining
- data_positive_shell_remaining
- data_flat_washer_remaining
- data_negative_shell_remaining
- data_spring_washer_remaining
- data_finished_battery_remaining_capacity
- data_finished_battery_ng_remaining_capacity
type: object
registry_type: device
version: 1.0.0

View File

@@ -324,7 +324,7 @@ neware_battery_test_system:
status_types:
channel_status: Dict[int, Dict]
connection_info: Dict[str, str]
device_summary: dict
device_summary: str
status: str
total_channels: int
type: python
@@ -339,9 +339,18 @@ neware_battery_test_system:
type: string
ip:
type: string
machine_id:
default: 1
type: integer
machine_ids:
default:
- 1
- 2
- 3
- 4
- 5
- 6
- 86
items:
type: integer
type: array
oss_prefix:
default: neware_backup
type: string
@@ -374,7 +383,7 @@ neware_battery_test_system:
type: string
type: object
device_summary:
type: object
type: string
status:
type: string
total_channels:

76
unilabos/registry/devices/robot_arm.yaml Executable file → Normal file
View File

@@ -173,64 +173,48 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: object
type: UniLabJsonCommand
pick_and_place:
feedback: {}
goal: {}
feedback:
status: status
goal:
command: command
goal_default:
constraints: null
lift_height: null
move_group: null
option: null
resource: null
retry: null
speed: null
status: null
target: null
x_distance: null
y_distance: null
command: ''
handles: {}
placeholder_keys: {}
result: {}
result:
return_info: return_info
success: success
schema:
description: pick_and_place 显式参数UniLabJsonCommand
description: ''
properties:
feedback: {}
goal:
feedback:
additionalProperties: false
properties:
constraints:
items:
type: number
type: array
lift_height:
type: string
move_group:
type: string
option:
type: string
resource:
type: string
retry:
type: string
speed:
type: string
status:
type: string
target:
type: string
x_distance:
type: string
y_distance:
type: string
required:
- option
- move_group
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
title: SendCmd_Result
type: object
result: {}
required:
- goal
title: pick_and_place参数
title: SendCmd
type: object
type: UniLabJsonCommand
type: SendCmd
set_position:
feedback:
status: status

76
unilabos/registry/devices/robot_linear_motion.yaml Executable file → Normal file
View File

@@ -684,64 +684,48 @@ linear_motion.toyo_xyz.sim:
type: object
type: UniLabJsonCommand
pick_and_place:
feedback: {}
goal: {}
feedback:
status: status
goal:
command: command
goal_default:
constraints: null
lift_height: null
move_group: null
option: null
resource: null
retry: null
speed: null
status: null
target: null
x_distance: null
y_distance: null
command: ''
handles: {}
placeholder_keys: {}
result: {}
result:
return_info: return_info
success: success
schema:
description: pick_and_place 显式参数UniLabJsonCommand
description: ''
properties:
feedback: {}
goal:
feedback:
additionalProperties: false
properties:
constraints:
items:
type: number
type: array
lift_height:
type: string
move_group:
type: string
option:
type: string
resource:
type: string
retry:
type: string
speed:
type: string
status:
type: string
target:
type: string
x_distance:
type: string
y_distance:
type: string
required:
- option
- move_group
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
title: SendCmd_Result
type: object
result: {}
required:
- goal
title: pick_and_place参数
title: SendCmd
type: object
type: UniLabJsonCommand
type: SendCmd
set_position:
feedback:
status: status

View File

@@ -2804,6 +2804,203 @@ virtual_rotavap:
- vacuum_pressure
type: object
version: 1.0.0
virtual_sample_demo:
category:
- virtual_device
class:
action_value_mappings:
analyze_readings:
feedback: {}
goal:
readings: readings
samples: samples
goal_default:
readings: null
samples: null
handles:
input:
- data_key: readings
data_source: handle
data_type: sample_list
handler_key: readings_in
label: 测量读数
- data_key: samples
data_source: handle
data_type: sample_index
handler_key: samples_in
label: 样品索引
output:
- data_key: scores
data_source: executor
data_type: sample_list
handler_key: scores_out
label: 分析得分
- data_key: passed
data_source: executor
data_type: sample_list
handler_key: passed_out
label: 是否通过
- data_key: samples
data_source: executor
data_type: sample_index
handler_key: samples_result_out
label: 样品索引
placeholder_keys: {}
result: {}
schema:
description: 对 split_and_measure 输出做二次分析,入参和出参都带 samples 列
properties:
feedback:
title: AnalyzeReadings_Feedback
goal:
properties:
readings:
description: 测量读数(来自 split_and_measure
items:
type: number
type: array
samples:
description: 每行归属的输入样品 index (0-based)
items:
type: integer
type: array
required:
- readings
- samples
title: AnalyzeReadings_Goal
type: object
result:
title: AnalyzeReadings_Result
type: object
required:
- goal
title: analyze_readings参数
type: object
type: UniLabJsonCommandAsync
measure_samples:
feedback: {}
goal:
concentrations: concentrations
goal_default:
concentrations: null
handles:
output:
- data_key: concentrations
data_source: executor
data_type: sample_list
handler_key: concentrations_out
label: 浓度列表
- data_key: absorbance
data_source: executor
data_type: sample_list
handler_key: absorbance_out
label: 吸光度列表
placeholder_keys: {}
result: {}
schema:
description: 模拟光度测量,入参出参等长
properties:
feedback:
title: MeasureSamples_Feedback
goal:
properties:
concentrations:
description: 样品浓度列表
items:
type: number
type: array
required:
- concentrations
title: MeasureSamples_Goal
type: object
result:
title: MeasureSamples_Result
type: object
required:
- goal
title: measure_samples参数
type: object
type: UniLabJsonCommandAsync
split_and_measure:
feedback: {}
goal:
split_count: split_count
volumes: volumes
goal_default:
split_count: 3
volumes: null
handles:
output:
- data_key: readings
data_source: executor
data_type: sample_list
handler_key: readings_out
label: 测量读数
- data_key: samples
data_source: executor
data_type: sample_index
handler_key: samples_out
label: 样品索引
- data_key: volumes
data_source: executor
data_type: sample_list
handler_key: volumes_out
label: 均分体积
placeholder_keys: {}
result: {}
schema:
description: 均分样品后逐份测量,输出带 samples 列标注归属
properties:
feedback:
title: SplitAndMeasure_Feedback
goal:
properties:
split_count:
default: 3
description: 每个样品均分的份数
type: integer
volumes:
description: 样品体积列表
items:
type: number
type: array
required:
- volumes
title: SplitAndMeasure_Goal
type: object
result:
title: SplitAndMeasure_Result
type: object
required:
- goal
title: split_and_measure参数
type: object
type: UniLabJsonCommandAsync
module: unilabos.devices.virtual.virtual_sample_demo:VirtualSampleDemo
status_types:
status: str
type: python
config_info: []
description: Virtual sample tracking demo device
handles: []
icon: ''
init_param_schema:
config:
properties:
config:
type: object
device_id:
type: string
required: []
type: object
data:
properties:
status:
type: string
required:
- status
type: object
version: 1.0.0
virtual_separator:
category:
- virtual_device

View File

@@ -33,6 +33,8 @@ from unilabos.registry.decorators import (
is_not_action,
is_always_free,
get_topic_config,
NodeType,
normalize_enum_value,
)
from unilabos.registry.utils import (
ROSMsgNotFound,
@@ -112,7 +114,7 @@ class Registry:
# 统一入口
# ------------------------------------------------------------------
def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False):
def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False, external_only=False):
"""统一构建注册表入口。"""
if self._setup_called:
logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用")
@@ -123,24 +125,27 @@ class Registry:
)
# 1. AST 静态扫描 (快速, 无需 import)
self._run_ast_scan(devices_dirs, upload_registry=upload_registry)
self._run_ast_scan(devices_dirs, upload_registry=upload_registry, external_only=external_only)
# 2. Host node 内置设备
self._setup_host_node()
# 3. YAML 注册表加载 (兼容旧格式)
self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
for i, path in enumerate(self.registry_paths):
sys_path = path.parent
logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}")
sys.path.append(str(sys_path))
self.load_device_types(path, complete_registry=complete_registry)
if BasicConfig.enable_resource_load:
self.load_resource_types(path, upload_registry, complete_registry=complete_registry)
else:
logger.warning(
"[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载"
)
# 3. YAML 注册表加载 (兼容旧格式) — external_only 模式下跳过
if external_only:
logger.info("[UniLab Registry] external_only 模式: 跳过 YAML 注册表加载")
else:
self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
for i, path in enumerate(self.registry_paths):
sys_path = path.parent
logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}")
sys.path.append(str(sys_path))
self.load_device_types(path, complete_registry=complete_registry)
if BasicConfig.enable_resource_load:
self.load_resource_types(path, upload_registry, complete_registry=complete_registry)
else:
logger.warning(
"[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载"
)
self._startup_executor.shutdown(wait=True)
self._startup_executor = None
self._setup_called = True
@@ -156,9 +161,10 @@ class Registry:
ast_entry = self.device_type_registry.get("host_node", {})
ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {})
# 取出 AST 生成的 auto-method entries, 补充特定覆写
# 取出 AST 生成的 action entries, 补充特定覆写
test_latency_action = ast_actions.get("auto-test_latency", {})
test_resource_action = ast_actions.get("auto-test_resource", {})
manual_confirm_action = ast_actions.get("manual_confirm", {})
test_resource_action["handles"] = {
"input": [
{
@@ -231,9 +237,11 @@ class Registry:
"parent": "unilabos_nodes",
"class_name": "unilabos_class",
},
"always_free": True,
},
"test_latency": test_latency_action,
"auto-test_resource": test_resource_action,
"manual_confirm": manual_confirm_action,
},
"init_params": {},
},
@@ -253,7 +261,7 @@ class Registry:
# AST 静态扫描
# ------------------------------------------------------------------
def _run_ast_scan(self, devices_dirs=None, upload_registry=False):
def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only=False):
"""
执行 AST 静态扫描,从 Python 代码中提取 @device / @resource 装饰器元数据。
无需 import 任何驱动模块,速度极快。
@@ -298,16 +306,30 @@ class Registry:
extra_dirs.append(d_path)
# 主扫描
exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None
scan_result = scan_directory(
scan_root, python_path=python_path, executor=self._startup_executor,
exclude_files=exclude_files, cache=ast_cache,
)
if exclude_files:
logger.info(
f"[UniLab Registry] 排除扫描文件: {exclude_files} "
f"(可通过 --extra_resource 启用加载)"
if external_only:
core_files = [
pkg_root / "ros" / "nodes" / "presets" / "host_node.py",
pkg_root / "resources" / "container.py",
]
scan_result = scan_directory(
scan_root, python_path=python_path, executor=self._startup_executor,
cache=ast_cache, include_files=core_files,
)
logger.info(
f"[UniLab Registry] external_only 模式: 仅扫描核心文件 "
f"({', '.join(f.name for f in core_files)})"
)
else:
exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None
scan_result = scan_directory(
scan_root, python_path=python_path, executor=self._startup_executor,
exclude_files=exclude_files, cache=ast_cache,
)
if exclude_files:
logger.info(
f"[UniLab Registry] 排除扫描文件: {exclude_files} "
f"(可通过 --extra_resource 启用加载)"
)
# 合并缓存统计
total_stats = scan_result.pop("_cache_stats", {"hits": 0, "misses": 0, "total": 0})
@@ -830,6 +852,9 @@ class Registry:
}
if (action_args or {}).get("always_free") or method_info.get("always_free"):
entry["always_free"] = True
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
if nt:
entry["node_type"] = nt
return action_name, entry
# 1) auto- actions
@@ -954,6 +979,9 @@ class Registry:
}
if action_args.get("always_free") or method_info.get("always_free"):
action_entry["always_free"] = True
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
if nt:
action_entry["node_type"] = nt
action_value_mappings[action_name] = action_entry
action_value_mappings = dict(sorted(action_value_mappings.items()))
@@ -1136,7 +1164,7 @@ class Registry:
return Path(BasicConfig.working_dir) / "registry_cache.pkl"
return None
_CACHE_VERSION = 3
_CACHE_VERSION = 4
def _load_config_cache(self) -> dict:
import pickle
@@ -1534,9 +1562,9 @@ class Registry:
del resource_info["config_info"]
if "file_path" in resource_info:
del resource_info["file_path"]
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
resource_info["registry_type"] = "resource"
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
for rid in skip_ids:
data.pop(rid, None)
@@ -1861,6 +1889,9 @@ class Registry:
}
if v.get("always_free"):
entry["always_free"] = True
old_node_type = old_cfg.get("node_type")
if old_node_type in [NodeType.ILAB.value, NodeType.MANUAL_CONFIRM.value]:
entry["node_type"] = old_node_type
device_config["class"]["action_value_mappings"][action_key] = entry
device_config["init_param_schema"] = {}
@@ -2175,7 +2206,7 @@ class Registry:
lab_registry = Registry()
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False):
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False):
"""
构建或获取Registry单例实例
"""
@@ -2189,7 +2220,7 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False
if path not in current_paths:
lab_registry.registry_paths.append(path)
lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry)
lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only)
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
lab_registry.resolve_all_types()

View File

@@ -0,0 +1,12 @@
YIHUA_Electrolyte_12VialCarrier:
category:
- battery_bottle_carriers
class:
module: unilabos.resources.battery.bottle_carriers:YIHUA_Electrolyte_12VialCarrier
type: pylabrobot
description: YIHUA 12-vial electrolyte carrier for coin cell assembly workstation
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -1,84 +1,140 @@
YB_20ml_fenyeping:
YB_Vial_20mL:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_20ml_fenyeping
module: unilabos.resources.bioyond.YB_bottles:YB_Vial_20mL
type: pylabrobot
description: YB_20ml_fenyeping
description: YB_Vial_20mL
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_5ml_fenyeping:
YB_Vial_5mL:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping
module: unilabos.resources.bioyond.YB_bottles:YB_Vial_5mL
type: pylabrobot
description: YB_5ml_fenyeping
description: YB_Vial_5mL
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_jia_yang_tou_da:
YB_DosingHead_L:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da
module: unilabos.resources.bioyond.YB_bottles:YB_DosingHead_L
type: pylabrobot
description: YB_jia_yang_tou_da
description: YB_DosingHead_L
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_pei_ye_da_Bottle:
YB_PrepBottle_60mL:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_da_Bottle
module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_60mL
type: pylabrobot
description: YB_pei_ye_da_Bottle
description: YB_PrepBottle_60mL
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_pei_ye_xiao_Bottle:
YB_PrepBottle_15mL:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle
module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_15mL
type: pylabrobot
description: YB_pei_ye_xiao_Bottle
description: YB_PrepBottle_15mL
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_qiang_tou:
YB_Tip_5000uL:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_qiang_tou
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_5000uL
type: pylabrobot
description: YB_qiang_tou
description: YB_Tip_5000uL
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_ye_Bottle:
YB_Tip_1000uL:
category:
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_1000uL
type: pylabrobot
description: YB_Tip_1000uL
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_Tip_50uL:
category:
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_50uL
type: pylabrobot
description: YB_Tip_50uL
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_NormalLiq_250mL_Bottle:
category:
- yb3
- YB_bottle_carriers
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle
module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_250mL_Bottle
type: pylabrobot
description: YB_ye_Bottle
description: YB_NormalLiq_250mL_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_NormalLiq_100mL_Bottle:
category:
- YB_bottle_carriers
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_100mL_Bottle
type: pylabrobot
description: YB_NormalLiq_100mL_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_HighVis_250mL_Bottle:
category:
- YB_bottle_carriers
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_250mL_Bottle
type: pylabrobot
description: YB_HighVis_250mL_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_HighVis_100mL_Bottle:
category:
- YB_bottle_carriers
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_100mL_Bottle
type: pylabrobot
description: YB_HighVis_100mL_Bottle
handles: []
icon: ''
init_param_schema: {}

View File

@@ -1,42 +1,29 @@
YB_100ml_yeti:
YB_Vial_20mL_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_100ml_yeti
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_20mL_Carrier
type: pylabrobot
description: YB_100ml_yeti
description: YB_Vial_20mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_20ml_fenyepingban:
YB_Vial_5mL_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_20ml_fenyepingban
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_5mL_Carrier
type: pylabrobot
description: YB_20ml_fenyepingban
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_5ml_fenyepingban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban
type: pylabrobot
description: YB_5ml_fenyepingban
description: YB_Vial_5mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_6StockCarrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier
@@ -45,10 +32,10 @@ YB_6StockCarrier:
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_6VialCarrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier
@@ -57,112 +44,137 @@ YB_6VialCarrier:
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_gao_nian_ye_Bottle:
YB_DosingHead_L_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottles:YB_gao_nian_ye_Bottle
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_DosingHead_L_Carrier
type: pylabrobot
description: YB_gao_nian_ye_Bottle
description: YB_DosingHead_L_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_gaonianye:
YB_PrepBottle_60mL_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_gaonianye
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_60mL_Carrier
type: pylabrobot
description: YB_gaonianye
description: YB_PrepBottle_60mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_jia_yang_tou_da_Carrier:
YB_PrepBottle_15mL_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_Carrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_15mL_Carrier
type: pylabrobot
description: YB_jia_yang_tou_da_Carrier
description: YB_PrepBottle_15mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_peiyepingdaban:
YB_TipRack_Mixed:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingdaban
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_Mixed
type: pylabrobot
description: YB_peiyepingdaban
description: YB_TipRack_Mixed
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_peiyepingxiaoban:
YB_TipRack_5000uL:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_5000uL
type: pylabrobot
description: YB_peiyepingxiaoban
description: YB_TipRack_5000uL
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_qiang_tou_he:
YB_TipRack_50uL:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_qiang_tou_he
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_50uL
type: pylabrobot
description: YB_qiang_tou_he
description: YB_TipRack_50uL
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_shi_pei_qi_kuai:
YB_Adapter_60mL:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_shi_pei_qi_kuai
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Adapter_60mL
type: pylabrobot
description: YB_shi_pei_qi_kuai
description: YB_Adapter_60mL
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_ye:
YB_NormalLiq_250mL_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_ye
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_250mL_Carrier
type: pylabrobot
description: YB_ye_Bottle_Carrier
description: YB_NormalLiq_250mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_ye_100ml_Bottle:
YB_NormalLiq_100mL_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottles:YB_ye_100ml_Bottle
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_100mL_Carrier
type: pylabrobot
description: YB_ye_100ml_Bottle
description: YB_NormalLiq_100mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_HighVis_250mL_Carrier:
category:
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_250mL_Carrier
type: pylabrobot
description: YB_HighVis_250mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_HighVis_100mL_Carrier:
category:
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_100mL_Carrier
type: pylabrobot
description: YB_HighVis_100mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -20,22 +20,22 @@ BIOYOND_PolymerReactionStation_Deck:
icon: 反应站.webp
init_param_schema: {}
version: 1.0.0
BIOYOND_YB_Deck:
BioyondElectrolyteDeck:
category:
- deck
class:
module: unilabos.resources.bioyond.decks:YB_Deck
module: unilabos.resources.bioyond.decks:bioyond_electrolyte_deck
type: pylabrobot
description: BIOYOND ElectrolyteFormulationStation Deck
handles: []
icon: 配液站.webp
init_param_schema: {}
version: 1.0.0
CoincellDeck:
YihuaCoinCellDeck:
category:
- deck
class:
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:YH_Deck
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:yihua_coin_cell_deck
type: pylabrobot
description: YIHUA CoinCellAssembly Deck
handles: []

View File

@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance
from unilabos.utils.cls_creator import import_class
from unilabos.registry.decorators import Side, DataSource, normalize_enum_value
_logger = logging.getLogger(__name__)
@@ -487,10 +488,7 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
}
side = h.get("side")
if side:
if isinstance(side, str) and "." in side:
val = side.rsplit(".", 1)[-1]
side = val.lower() if val in ("LEFT", "RIGHT", "TOP", "BOTTOM") else val
entry["side"] = side
entry["side"] = normalize_enum_value(side, Side) or side
label = h.get("label")
if label:
entry["label"] = label
@@ -499,10 +497,7 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
entry["data_key"] = data_key
data_source = h.get("data_source")
if data_source:
if isinstance(data_source, str) and "." in data_source:
val = data_source.rsplit(".", 1)[-1]
data_source = val.lower() if val in ("HANDLE", "EXECUTOR") else val
entry["data_source"] = data_source
entry["data_source"] = normalize_enum_value(data_source, DataSource) or data_source
description = h.get("description")
if description:
entry["description"] = description
@@ -537,17 +532,12 @@ def normalize_ast_action_handles(handles_raw: Any) -> Dict[str, Any]:
"data_type": h.get("data_type", ""),
"label": h.get("label", ""),
}
_FIELD_ENUM_MAP = {"side": Side, "data_source": DataSource}
for opt_key in ("side", "data_key", "data_source", "description", "io_type"):
val = h.get(opt_key)
if val is not None:
# Only resolve enum-style refs (e.g. DataSource.HANDLE -> handle) for data_source/side
# data_key values like "wells.@flatten", "@this.0@@@plate" must be preserved as-is
if (
isinstance(val, str)
and "." in val
and opt_key not in ("io_type", "data_key")
):
val = val.rsplit(".", 1)[-1].lower()
if opt_key in _FIELD_ENUM_MAP:
val = normalize_enum_value(val, _FIELD_ENUM_MAP[opt_key]) or val
entry[opt_key] = val
# io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it)

View File

@@ -1,9 +1,6 @@
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
@@ -51,6 +48,5 @@ def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier:
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}")
# 载架初始化为空,瓶子由实际转运操作填入,避免反序列化时重复 assign
return carrier

View File

@@ -53,13 +53,28 @@ class Magazine(ResourceStack):
return self.get_size_z()
def serialize(self) -> dict:
return {
**super().serialize(),
data = super().serialize()
# 物料余量由寄存器接管,不再持久化极片子节点,
# 防止旧数据写回数据库后下次启动时再次引发重复 UUID。
data["children"] = []
data.update({
"size_x": self.size_x or 10.0,
"size_y": self.size_y or 10.0,
"size_z": self.size_z or 10.0,
"max_sheets": self.max_sheets,
}
})
return data
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
"""反序列化时丢弃极片子节点ElectrodeSheet 等)。
物料余量已由寄存器接管,不再在资源树中追踪每个极片实体。
清空 children 可防止数据库中的旧极片记录被重新加载,避免重复 UUID 报错。
"""
data = dict(data)
data["children"] = []
return super().deserialize(data, allow_marshal=allow_marshal)
class MagazineHolder(ItemizedResource):
@@ -220,7 +235,7 @@ def MagazineHolder_6_Cathode(
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
klasses=None,
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
@@ -258,7 +273,7 @@ def MagazineHolder_6_Anode(
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan],
klasses=None,
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
@@ -335,7 +350,7 @@ def MagazineHolder_4_Cathode(
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode],
klasses=None,
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,

View File

@@ -2,15 +2,18 @@ from pylabrobot.resources import create_homogeneous_resources, Coordinate, Resou
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
from unilabos.resources.bioyond.YB_bottles import (
YB_jia_yang_tou_da,
YB_ye_Bottle,
YB_ye_100ml_Bottle,
YB_gao_nian_ye_Bottle,
YB_5ml_fenyeping,
YB_20ml_fenyeping,
YB_pei_ye_xiao_Bottle,
YB_pei_ye_da_Bottle,
YB_qiang_tou,
YB_DosingHead_L,
YB_NormalLiq_250mL_Bottle,
YB_NormalLiq_100mL_Bottle,
YB_HighVis_250mL_Bottle,
YB_HighVis_100mL_Bottle,
YB_Vial_5mL,
YB_Vial_20mL,
YB_PrepBottle_15mL,
YB_PrepBottle_60mL,
YB_Tip_5000uL,
YB_Tip_1000uL,
YB_Tip_50uL,
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
@@ -206,7 +209,7 @@ def YB_6VialCarrier(name: str) -> BottleCarrier:
return carrier
# 1瓶载架 - 单个中央位置
def YB_ye(name: str) -> BottleCarrier:
def YB_NormalLiq_250mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -233,17 +236,17 @@ def YB_ye(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_ye",
model="YB_NormalLiq_250mL_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_ye_Bottle(f"{name}_flask_1")
carrier[0] = YB_NormalLiq_250mL_Bottle(f"{name}_flask_1")
return carrier
# 高粘液瓶载架 - 单个中央位置
def YB_gaonianye(name: str) -> BottleCarrier:
def YB_HighVis_250mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -270,17 +273,17 @@ def YB_gaonianye(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_gaonianye",
model="YB_HighVis_250mL_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_gao_nian_ye_Bottle(f"{name}_flask_1")
carrier[0] = YB_HighVis_250mL_Bottle(f"{name}_flask_1")
return carrier
# 100ml液体瓶载架 - 单个中央位置
def YB_100ml_yeti(name: str) -> BottleCarrier:
# 100mL普通液瓶载架 - 单个中央位置
def YB_NormalLiq_100mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -307,16 +310,52 @@ def YB_100ml_yeti(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_100ml_yeti",
model="YB_NormalLiq_100mL_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_ye_100ml_Bottle(f"{name}_flask_1")
carrier[0] = YB_NormalLiq_100mL_Bottle(f"{name}_flask_1")
return carrier
# 5ml分液瓶板 - 4x2布局8个位置
def YB_5ml_fenyepingban(name: str) -> BottleCarrier:
# 100mL高粘液瓶载架 - 单个中央位置
def YB_HighVis_100mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_HighVis_100mL_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_HighVis_100mL_Bottle(f"{name}_flask_1")
return carrier
# 5mL分液瓶板 - 4x2布局8个位置
def YB_Vial_5mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -355,18 +394,18 @@ def YB_5ml_fenyepingban(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_5ml_fenyepingban",
model="YB_Vial_5mL_Carrier",
)
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_5ml_fenyeping(f"{name}_vial_{ordering[i]}")
carrier[i] = YB_Vial_5mL(f"{name}_vial_{ordering[i]}")
return carrier
# 20ml分液瓶板 - 4x2布局8个位置
def YB_20ml_fenyepingban(name: str) -> BottleCarrier:
# 20mL分液瓶板 - 4x2布局8个位置
def YB_Vial_20mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -405,18 +444,18 @@ def YB_20ml_fenyepingban(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_20ml_fenyepingban",
model="YB_Vial_20mL_Carrier",
)
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_20ml_fenyeping(f"{name}_vial_{ordering[i]}")
carrier[i] = YB_Vial_20mL(f"{name}_vial_{ordering[i]}")
return carrier
# 配液瓶(小)板 - 4x2布局8个位置
def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
def YB_PrepBottle_15mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -455,19 +494,19 @@ def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_peiyepingxiaoban",
model="YB_PrepBottle_15mL_Carrier",
)
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_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}")
carrier[i] = YB_PrepBottle_15mL(f"{name}_bottle_{ordering[i]}")
return carrier
# 配液瓶(大)板 - 2x2布局4个位置
def YB_peiyepingdaban(name: str) -> BottleCarrier:
def YB_PrepBottle_60mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -505,18 +544,18 @@ def YB_peiyepingdaban(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_peiyepingdaban",
model="YB_PrepBottle_60mL_Carrier",
)
carrier.num_items_x = 2
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "B1", "B2"]
for i in range(4):
carrier[i] = YB_pei_ye_da_Bottle(f"{name}_bottle_{ordering[i]}")
carrier[i] = YB_PrepBottle_60mL(f"{name}_bottle_{ordering[i]}")
return carrier
# 加样头(大)板 - 1x1布局1个位置
def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier:
def YB_DosingHead_L_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -554,16 +593,16 @@ def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_jia_yang_tou_da_Carrier",
model="YB_DosingHead_L_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_jia_yang_tou_da(f"{name}_head_1")
carrier[0] = YB_DosingHead_L(f"{name}_head_1")
return carrier
def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
def YB_Adapter_60mL(name: str) -> BottleCarrier:
"""适配器块 - 单个中央位置"""
# 载架尺寸 (mm)
@@ -591,7 +630,7 @@ def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
resource_size_y=adapter_diameter,
name_prefix=name,
),
model="YB_shi_pei_qi_kuai",
model="YB_Adapter_60mL",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
@@ -600,7 +639,7 @@ def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
return carrier
def YB_qiang_tou_he(name: str) -> BottleCarrier:
def YB_TipRack_50uL(name: str) -> BottleCarrier:
"""枪头盒 - 8x12布局96个位置"""
# 载架尺寸 (mm)
@@ -609,9 +648,9 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier:
carrier_size_z = 55.0
# 枪头尺寸
tip_diameter = 10.0
tip_spacing_x = 9.0 # X方向间距
tip_spacing_y = 9.0 # Y方向间距
tip_diameter = 7.0
tip_spacing_x = 7.5 # X方向间距
tip_spacing_y = 7.5 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (12 - 1) * tip_spacing_x - tip_diameter) / 2
@@ -639,7 +678,7 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_qiang_tou_he",
model="YB_TipRack_50uL",
)
carrier.num_items_x = 12
carrier.num_items_y = 8
@@ -648,6 +687,182 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier:
for i in range(96):
row = chr(65 + i // 12) # A-H
col = (i % 12) + 1 # 1-12
carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}")
carrier[i] = YB_Tip_50uL(f"{name}_tip_{row}{col}")
return carrier
def YB_TipRack_5000uL(name: str) -> BottleCarrier:
"""枪头盒 - 4x6布局24个位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 95.0
# 枪头尺寸
tip_diameter = 16.0
tip_spacing_x = 16.5 # X方向间距
tip_spacing_y = 16.5 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (6 - 1) * tip_spacing_x - tip_diameter) / 2
start_y = (carrier_size_y - (4 - 1) * tip_spacing_y - tip_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=6,
num_items_y=4,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=tip_spacing_x,
item_dy=tip_spacing_y,
size_x=tip_diameter,
size_y=tip_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="YB_TipRack_5000uL",
)
carrier.num_items_x = 6
carrier.num_items_y = 4
carrier.num_items_z = 1
# 创建24个枪头
for i in range(24):
row = chr(65 + i // 6) # A-D
col = (i % 6) + 1 # 1-6
carrier[i] = YB_Tip_5000uL(f"{name}_tip_{row}{col}")
return carrier
def YB_TipRack_Mixed(name: str) -> BottleCarrier:
"""混合枪头盒 - 复杂布局
上层: 2x8空位原50uL枪头位置现空余
中层: 4x4布局放5000uL枪头
下层: 2x8布局放1000uL枪头
"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 95.0
# 各类枪头的尺寸参数
tip_5000_diameter = 16.0
tip_5000_spacing_x = 16.5
tip_5000_spacing_y = 16.5
tip_1000_diameter = 7.0
tip_1000_spacing_x = 7.5
tip_1000_spacing_y = 7.5
# 空位尺寸上层2x8原50uL位置
empty_diameter = 7.0
empty_spacing_x = 7.5
empty_spacing_y = 7.5
# 计算各层的起始位置
# 上层空位 (2x8)
empty_top_start_x = (carrier_size_x - (8 - 1) * empty_spacing_x - empty_diameter) / 2
empty_top_start_y = 5.0
# 中层5000uL (4x4)
tip_5000_start_x = (carrier_size_x - (4 - 1) * tip_5000_spacing_x - tip_5000_diameter) / 2
tip_5000_start_y = empty_top_start_y + 2 * empty_spacing_y + 5.0
# 下层1000uL (2x8)
tip_1000_start_x = (carrier_size_x - (8 - 1) * tip_1000_spacing_x - tip_1000_diameter) / 2
tip_1000_start_y = tip_5000_start_y + 4 * tip_5000_spacing_y + 5.0
sites = {}
# 创建上层空位 (2x8) - 不创建实际的枪头对象
empty_top_sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=8,
num_items_y=2,
dx=empty_top_start_x,
dy=empty_top_start_y,
dz=5.0,
item_dx=empty_spacing_x,
item_dy=empty_spacing_y,
size_x=empty_diameter,
size_y=empty_diameter,
size_z=carrier_size_z,
)
# 添加空位,索引 0-15
for k, v in empty_top_sites.items():
v.name = f"{name}_empty_top_{v.name}"
sites[k] = v
# 创建中层5000uL枪头位 (4x4),索引 16-31
tip_5000_sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=4,
num_items_y=4,
dx=tip_5000_start_x,
dy=tip_5000_start_y,
dz=15.0,
item_dx=tip_5000_spacing_x,
item_dy=tip_5000_spacing_y,
size_x=tip_5000_diameter,
size_y=tip_5000_diameter,
size_z=carrier_size_z,
)
for i, (k, v) in enumerate(tip_5000_sites.items()):
v.name = f"{name}_5000_{v.name}"
sites[16 + i] = v
# 创建下层1000uL枪头位 (2x8),索引 32-47
tip_1000_sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=8,
num_items_y=2,
dx=tip_1000_start_x,
dy=tip_1000_start_y,
dz=25.0,
item_dx=tip_1000_spacing_x,
item_dy=tip_1000_spacing_y,
size_x=tip_1000_diameter,
size_y=tip_1000_diameter,
size_z=carrier_size_z,
)
for i, (k, v) in enumerate(tip_1000_sites.items()):
v.name = f"{name}_1000_{v.name}"
sites[32 + i] = v
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_TipRack_Mixed",
)
carrier.num_items_x = 8 # 最大宽度
carrier.num_items_y = 8 # 总行数 (2+4+2)
carrier.num_items_z = 1
# 为5000uL枪头创建实例 (16个),对应索引 16-31
for i in range(16):
row = chr(65 + i // 4) # A-D
col = (i % 4) + 1 # 1-4
carrier[16 + i] = YB_Tip_5000uL(f"{name}_tip5000_{row}{col}")
# 为1000uL枪头创建实例 (16个),对应索引 32-47
for i in range(16):
row = chr(65 + i // 8) # A-B
col = (i % 8) + 1 # 1-8
carrier[32 + i] = YB_Tip_1000uL(f"{name}_tip1000_{row}{col}")
return carrier

View File

@@ -1,7 +1,7 @@
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
# 工厂函数
"""加样头(大)"""
def YB_jia_yang_tou_da(
def YB_DosingHead_L(
name: str,
diameter: float = 20.0,
height: float = 100.0,
@@ -15,11 +15,11 @@ def YB_jia_yang_tou_da(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_jia_yang_tou_da",
model="YB_DosingHead_L",
)
"""1x1"""
def YB_ye_Bottle(
"""250mL普通"""
def YB_NormalLiq_250mL_Bottle(
name: str,
diameter: float = 40.0,
height: float = 70.0,
@@ -33,87 +33,105 @@ def YB_ye_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_ye_Bottle",
model="YB_NormalLiq_250mL_Bottle",
)
"""100ml液体"""
def YB_ye_100ml_Bottle(
"""100mL普通液"""
def YB_NormalLiq_100mL_Bottle(
name: str,
diameter: float = 50.0,
height: float = 90.0,
max_volume: float = 100000.0, # 100mL
barcode: str = None,
) -> Bottle:
"""创建100ml液体"""
"""创建100mL普通液"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_100ml_yeti",
model="YB_NormalLiq_100mL_Bottle",
)
"""高粘液"""
def YB_gao_nian_ye_Bottle(
"""100mL高粘液"""
def YB_HighVis_100mL_Bottle(
name: str,
diameter: float = 50.0,
height: float = 90.0,
max_volume: float = 100000.0, # 100mL
barcode: str = None,
) -> Bottle:
"""创建100mL高粘液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_HighVis_100mL_Bottle",
)
"""250mL高粘液"""
def YB_HighVis_250mL_Bottle(
name: str,
diameter: float = 40.0,
height: float = 70.0,
max_volume: float = 50000.0, # 50mL
barcode: str = None,
) -> Bottle:
"""创建高粘液瓶"""
"""创建250mL高粘液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="High_Viscosity_Liquid",
model="YB_HighVis_250mL_Bottle",
)
"""5ml分液瓶"""
def YB_5ml_fenyeping(
"""5mL分液瓶"""
def YB_Vial_5mL(
name: str,
diameter: float = 20.0,
height: float = 50.0,
max_volume: float = 5000.0, # 5mL
barcode: str = None,
) -> Bottle:
"""创建5ml分液瓶"""
"""创建5mL分液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_5ml_fenyeping",
model="YB_Vial_5mL",
)
"""20ml分液瓶"""
def YB_20ml_fenyeping(
"""20mL分液瓶"""
def YB_Vial_20mL(
name: str,
diameter: float = 30.0,
height: float = 65.0,
max_volume: float = 20000.0, # 20mL
barcode: str = None,
) -> Bottle:
"""创建20ml分液瓶"""
"""创建20mL分液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_20ml_fenyeping",
model="YB_Vial_20mL",
)
"""配液瓶(小)"""
def YB_pei_ye_xiao_Bottle(
def YB_PrepBottle_15mL(
name: str,
diameter: float = 35.0,
height: float = 60.0,
max_volume: float = 30000.0, # 30mL
max_volume: float = 15000.0, # 15mL
barcode: str = None,
) -> Bottle:
"""创建配液瓶(小)"""
@@ -123,15 +141,15 @@ def YB_pei_ye_xiao_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_pei_ye_xiao_Bottle",
model="YB_PrepBottle_15mL",
)
"""配液瓶(大)"""
def YB_pei_ye_da_Bottle(
def YB_PrepBottle_60mL(
name: str,
diameter: float = 55.0,
height: float = 100.0,
max_volume: float = 150000.0, # 150mL
max_volume: float = 60000.0, # 60mL
barcode: str = None,
) -> Bottle:
"""创建配液瓶(大)"""
@@ -141,11 +159,29 @@ def YB_pei_ye_da_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_pei_ye_da_Bottle",
model="YB_PrepBottle_60mL",
)
"""枪头"""
def YB_qiang_tou(
"""5000uL枪头"""
def YB_Tip_5000uL(
name: str,
diameter: float = 10.0,
height: float = 50.0,
max_volume: float = 5000.0, # 5mL
barcode: str = None,
) -> Bottle:
"""创建枪头"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_Tip_5000uL",
)
"""1000uL枪头"""
def YB_Tip_1000uL(
name: str,
diameter: float = 10.0,
height: float = 50.0,
@@ -159,5 +195,23 @@ def YB_qiang_tou(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_qiang_tou",
model="YB_Tip_1000uL",
)
"""50uL枪头"""
def YB_Tip_50uL(
name: str,
diameter: float = 10.0,
height: float = 50.0,
max_volume: float = 50.0, # 50uL
barcode: str = None,
) -> Bottle:
"""创建枪头"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_Tip_50uL",
)

View File

@@ -1,4 +1,3 @@
from os import name
from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.resources.bioyond.YB_warehouses import (
@@ -34,11 +33,8 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
if setup:
self.setup()
def setup(self) -> None:
# 添加仓库
@@ -66,6 +62,7 @@ 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,
@@ -74,11 +71,8 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
if setup:
self.setup()
def setup(self) -> None:
# 添加仓库 - 配液站的3个堆栈使用Bioyond系统中的实际名称
@@ -101,7 +95,8 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_YB_Deck(Deck):
class BioyondElectrolyteDeck(Deck):
def __init__(
self,
name: str = "YB_Deck",
@@ -109,7 +104,7 @@ class BIOYOND_YB_Deck(Deck):
size_y: float = 1400.0,
size_z: float = 2670.0,
category: str = "deck",
setup: bool = False
setup: bool = False,
) -> None:
super().__init__(name=name, size_x=4150.0, size_y=1400.0, size_z=2670.0)
if setup:
@@ -118,8 +113,8 @@ class BIOYOND_YB_Deck(Deck):
def setup(self) -> None:
# 添加仓库
self.warehouses = {
"321窗口": bioyond_warehouse_2x2x1("321窗口"), # 2行×2列
"43窗口": bioyond_warehouse_2x2x1("43窗口"), # 2行×2列
"自动堆栈-左": bioyond_warehouse_2x2x1("自动堆栈-左"), # 2行×2列
"自动堆栈-右": bioyond_warehouse_2x2x1("自动堆栈-右"), # 2行×2列
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03
"手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03
"加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"),
@@ -133,29 +128,34 @@ class BIOYOND_YB_Deck(Deck):
}
# warehouse 的位置
self.warehouse_locations = {
"321窗口": Coordinate(-150.0, 158.0, 0.0),
"43窗口": Coordinate(4160.0, 158.0, 0.0),
"手动传递窗左": Coordinate(-150.0, 877.0, 0.0),
"手动传递窗右": Coordinate(4160.0, 877.0, 0.0),
"加样头堆栈左": Coordinate(385.0, 1300.0, 0.0),
"加样头堆栈右": Coordinate(2187.0, 1300.0, 0.0),
"自动堆栈-左": Coordinate(-150.0, 1142.0, 0.0),
"自动堆栈-右": Coordinate(4160.0, 1142.0, 0.0),
"手动传递窗左": Coordinate(-150.0, 423.0, 0.0),
"手动传递窗右": Coordinate(4160.0, 423.0, 0.0),
"加样头堆栈左": Coordinate(385.0, 0, 0.0),
"加样头堆栈右": Coordinate(2187.0, 0, 0.0),
"15ml配液堆栈左": Coordinate(749.0, 355.0, 0.0),
"母液加样右": Coordinate(2152.0, 333.0, 0.0),
"大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0),
"大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0),
"2号手套箱内部堆栈": Coordinate(-800, -500.0, 0.0), # 新增:位置需根据实际硬件调整
"15ml配液堆栈左": Coordinate(749.0, 945.0, 0.0),
"母液加样右": Coordinate(2152.0, 967.0, 0.0),
"大瓶母液堆栈左": Coordinate(1164.0, 624.0, 0.0),
"大瓶母液堆栈右": Coordinate(2717.0, 624.0, 0.0),
"2号手套箱内部堆栈": Coordinate(-800, 800.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
# 向后兼容别名,日后废弃
BIOYOND_YB_Deck = BioyondElectrolyteDeck
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
deck = BioyondElectrolyteDeck(name=name)
deck.setup()
return deck
# 向后兼容别名,日后废弃
def YB_Deck(name: str) -> BioyondElectrolyteDeck:
return bioyond_electrolyte_deck(name)

View File

@@ -42,7 +42,7 @@ def canonicalize_nodes_data(
Returns:
ResourceTreeSet: 标准化后的资源树集合
"""
print_status(f"{len(nodes)} Resources loaded:", "info")
print_status(f"{len(nodes)} Resources loaded", "info")
# 第一步基本预处理处理graphml的label字段
outer_host_node_id = None
@@ -593,7 +593,7 @@ def resource_ulab_to_plr(resource: dict, plr_model=False) -> "ResourcePLR":
"size_y": resource["config"].get("size_y", 0),
"size_z": resource["config"].get("size_z", 0),
"location": {**resource["position"], "type": "Coordinate"},
"rotation": {resource["config"].get("rotation", {"x": 0, "y": 0, "z": 0, "type": "Rotation"})}, # Resource如果没有rotation是plr版本太低
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, # Resource如果没有rotation是plr版本太低
"category": resource["type"],
"model": resource["config"].get("model", None), # resource中deck没有model
"children": (
@@ -797,9 +797,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
bottle = plr_material[number] = initialize_resource(
{"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR
)
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
bottle.code = detail.get("code", "")
logger.debug(f" └─ [子物料] {detail['name']}{plr_material.name}[{number}] (类型:{typeName})")
else:
@@ -808,9 +809,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
# 只对有 capacity 属性的容器(液体容器)处理液体追踪
if hasattr(plr_material, 'capacity'):
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
plr_materials.append(plr_material)

View File

@@ -179,6 +179,35 @@ class ItemizedCarrier(ResourcePLR):
idx = i
break
if idx is None and location is not None:
# 精确坐标匹配失败常见原因DB 存储的 z=0而槽位定义 z=dz>0
# 降级为仅按 XY 坐标进行近似匹配,找到后使用槽位自身的正确坐标写回,
# 避免因 Z 偏移导致反序列化中断。
_XY_TOLERANCE = 2.0 # mm覆盖浮点误差和 z 偏移
min_dist = float("inf")
nearest_idx = None
for _i, _loc in enumerate(self.child_locations.values()):
_d = (((_loc.x - location.x) ** 2) + ((_loc.y - location.y) ** 2)) ** 0.5
if _d < min_dist:
min_dist = _d
nearest_idx = _i
if nearest_idx is not None and min_dist <= _XY_TOLERANCE:
from unilabos.utils.log import logger as _logger
_slot_label = list(self.child_locations.keys())[nearest_idx]
_logger.warning(
f"[ItemizedCarrier '{self.name}'] 资源 '{resource.name}' 坐标 {location} 与槽位 "
f"'{_slot_label}' {list(self.child_locations.values())[nearest_idx]} 的 XY 吻合"
f"XY 偏差={min_dist:.2f}mm按 XY 近似匹配成功z 偏移已被修正。"
)
idx = nearest_idx
if idx is None:
raise ValueError(
f"[ItemizedCarrier '{self.name}'] 无法为资源 '{resource.name}' 找到匹配的槽位。\n"
f" 已知槽位: {list(self.child_locations.keys())}\n"
f" 传入坐标: {location}\n"
f" 提示: XY 近似匹配也失败,请检查资源坐标或 Carrier 槽位定义是否正确。"
)
if not reassign and self.sites[idx] is not None:
raise ValueError(f"a site with index {idx} already exists")
location = list(self.child_locations.values())[idx]

View File

@@ -244,12 +244,6 @@ class ResourceDictInstance(object):
height= content["config"].get("size_y", 0),
depth= content["config"].get("size_z", 0),
)
if "rotation" not in pose:
pose["rotation"] = ResourceDictPositionObjectType(
x=content["config"].get("rotation", {}).get("x", 0),
y=content["config"].get("rotation", {}).get("y", 0),
z=content["config"].get("rotation", {}).get("z", 0),
)
content["pose"] = pose
try:
res_dict = ResourceDict.model_validate(content)
@@ -618,6 +612,31 @@ class ResourceTreeSet(object):
d["model"] = res.config.get("model", None)
return d
def _deduplicate_plr_dict(d: dict, _seen: set = None) -> dict:
"""递归去除 children 中同名重复节点(全树范围、保留首次出现)。
根本原因:同一槽位被 sync_from_externalBioyond 同步)重复写入,
导致数据库中同一 WareHouse 下存在多条同名 BottleCarrier 记录(不同 UUID
PLR 的 _check_naming_conflicts 在全树范围检查名称唯一性,
重复名称会在 deserialize 时抛出 ValueError导致节点启动失败。
此函数在 sub_cls.deserialize 前预先清理,保证名称唯一。
"""
if _seen is None:
_seen = set()
children = d.get("children", [])
deduped = []
for child in children:
child = _deduplicate_plr_dict(child, _seen)
cname = child.get("name")
if cname not in _seen:
_seen.add(cname)
deduped.append(child)
else:
logger.warning(
f"[资源树去重] 发现重复资源名称 '{cname}',跳过重复项(历史脏数据)"
)
return {**d, "children": deduped}
plr_resources = []
tracker = DeviceNodeResourceTracker()
@@ -628,6 +647,8 @@ class ResourceTreeSet(object):
collect_node_data(tree.root_node, name_to_uuid, all_states, name_to_extra)
has_model = tree.root_node.res_content.type != "deck"
plr_dict = node_to_plr_dict(tree.root_node, has_model)
plr_dict = _deduplicate_plr_dict(plr_dict)
try:
sub_cls = find_subclass(plr_dict["type"], PLRResource)
if skip_devices and plr_dict["type"] == "device":
@@ -646,6 +667,14 @@ class ResourceTreeSet(object):
location = cast(Coordinate, deserialize(plr_dict["location"]))
plr_resource.location = location
# 预填 Container 类型资源在新版 PLR 中要求必须存在的键,
# 防止旧数据库状态缺失这些键时 load_all_state 抛出 KeyError。
for state in all_states.values():
if isinstance(state, dict):
state.setdefault("liquid_history", [])
state.setdefault("pending_liquids", {})
plr_resource.load_all_state(all_states)
# 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra
tracker.loop_set_uuid(plr_resource, name_to_uuid)

View File

@@ -41,8 +41,9 @@ def warehouse_factory(
# 根据 layout 决定 y 坐标计算
if layout == "row-major":
# 行优先row=0(A行) 应该显示在上方,需要较小的 y 值
y = dy + row * item_dy
# 行优先row=0(A行) 应该显示在上方
# 前端现在 y 越大越靠上,所以 row=0 对应最大的 y
y = dy + (num_items_y - row - 1) * item_dy
elif layout == "vertical-col-major":
# 竖向warehouse: row=0 对应顶部y小row=n-1 对应底部y大
# 但标签 01 应该在底部,所以使用反向映射

View File

@@ -1,4 +1,5 @@
import json
import os
# from nt import device_encoding
import threading
@@ -61,7 +62,7 @@ def main(
rclpy.init(args=rclpy_init_args)
else:
logger.info("[ROS] rclpy already initialized, reusing context")
executor = rclpy.__executor = MultiThreadedExecutor()
executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
# 创建主机节点
host_node = HostNode(
"host_node",
@@ -90,7 +91,7 @@ def main(
device_id="resource_mesh_manager",
device_uuid=str(uuid.uuid4()),
)
joint_republisher = JointRepublisher("joint_republisher","", host_node.resource_tracker)
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
# lh_joint_pub = LiquidHandlerJointPublisher(
# resources_config=resources_list, resource_tracker=host_node.resource_tracker
# )
@@ -122,7 +123,7 @@ def slave(
rclpy.init(args=rclpy_init_args)
executor = rclpy.__executor
if not executor:
executor = rclpy.__executor = MultiThreadedExecutor()
executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
# 1.5 启动 executor 线程
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")

View File

@@ -486,18 +486,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if len(rts.root_nodes) == 1 and parent_resource is not None:
plr_instance = plr_instances[0]
if isinstance(plr_instance, Plate):
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
self.lab_logger().warning(
f"增加液体资源时数量为1自动补全为 {len(LIQUID_INPUT_SLOT)}"
)
for liquid_type, liquid_volume, liquid_input_slot in zip(
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in)
try:
# noinspection PyProtectedMember
keys = list(plr_instance._ordering.keys())
@@ -511,6 +505,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
input_wells = []
for r in LIQUID_INPUT_SLOT:
input_wells.append(plr_instance.children[r])
for input_well, liquid_type, liquid_volume, liquid_input_slot in zip(
input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
):
input_well.set_liquids([(liquid_type, liquid_volume, "uL")])
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
input_wells
).dump()
@@ -1256,9 +1254,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return self._lab_logger
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
"""创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建"""
# 检测 @topic_config 装饰器配置
topic_config = {}
"""创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数"""
topic_cfg = {}
driver_class = type(self.driver_instance)
# 区分 @property 和普通方法两种情况
@@ -1267,23 +1264,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
)
if is_prop:
# @property: 检测 fget 上的 @topic_config
class_attr = getattr(driver_class, attr_name)
if class_attr.fget is not None:
topic_config = get_topic_config(class_attr.fget)
topic_cfg = get_topic_config(class_attr.fget)
else:
# 普通方法: 直接检测 attr_name 方法上的 @topic_config
if hasattr(self.driver_instance, attr_name):
method = getattr(self.driver_instance, attr_name)
if callable(method):
topic_config = get_topic_config(method)
# 没有 @topic_config 装饰器则跳过发布
if not topic_config:
return
topic_cfg = get_topic_config(method)
# 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name
cfg_name = topic_config.get("name")
cfg_name = topic_cfg.get("name")
if cfg_name:
publish_name = cfg_name
elif attr_name.startswith("get_"):
@@ -1291,10 +1282,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else:
publish_name = attr_name
# 使用装饰器配置或默认值
cfg_period = topic_config.get("period")
cfg_print = topic_config.get("print_publish")
cfg_qos = topic_config.get("qos")
# @topic_config 参数覆盖默认值
cfg_period = topic_cfg.get("period")
cfg_print = topic_cfg.get("print_publish")
cfg_qos = topic_cfg.get("qos")
period: float = cfg_period if cfg_period is not None else initial_period
print_publish: bool = cfg_print if cfg_print is not None else self._print_publish
qos: int = cfg_qos if cfg_qos is not None else 10

View File

@@ -24,7 +24,7 @@ from unilabos_msgs.srv import (
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID
from unilabos.registry.decorators import device
from unilabos.registry.decorators import device, action, NodeType
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.registry.registry import lab_registry
from unilabos.resources.container import RegularContainer
@@ -313,7 +313,9 @@ class HostNode(BaseROS2DeviceNode):
callback_group=self.callback_group,
),
} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = {} # device_id -> action_value_mappings(本地+远程设备统一存储)
self._action_value_mappings: Dict[str, Dict] = {
device_id: self._action_value_mappings
} # device_id -> action_value_mappings(本地+远程设备统一存储)
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
@@ -1621,6 +1623,19 @@ class HostNode(BaseROS2DeviceNode):
}
return res
@action(always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
"assignee_user_ids": "unilabos_manual_confirm"
}, goal_default={
"timeout_seconds": 3600,
"assignee_user_ids": []
})
def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
修改的结果无效,是只读的
"""
return kwargs
def test_resource(
self,
sample_uuids: SampleUUIDsType,

View File

@@ -13,7 +13,7 @@
"deck": {
"data": {
"_resource_child_name": "YB_Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
"_resource_type": "unilabos.resources.bioyond.decks:BioyondElectrolyteDeck"
}
},
"protocol_type": [],
@@ -103,15 +103,14 @@
"children": [],
"parent": "bioyond_cell_workstation",
"type": "deck",
"class": "BIOYOND_YB_Deck",
"class": "BioyondElectrolyteDeck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_YB_Deck",
"setup": true,
"type": "BioyondElectrolyteDeck",
"rotation": {
"x": 0,
"y": 0,

View File

@@ -6,20 +6,180 @@
import argparse
import importlib
import locale
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
from unilabos.utils.banner_print import print_status
# ---------------------------------------------------------------------------
# 底层安装工具
# ---------------------------------------------------------------------------
def _is_chinese_locale() -> bool:
try:
lang = locale.getdefaultlocale()[0]
return bool(lang and ("zh" in lang.lower() or "chinese" in lang.lower()))
except Exception:
return False
_USE_UV: Optional[bool] = None
def _has_uv() -> bool:
global _USE_UV
if _USE_UV is None:
_USE_UV = shutil.which("uv") is not None
return _USE_UV
def _install_packages(
packages: List[str],
upgrade: bool = False,
label: str = "",
) -> bool:
"""
安装/升级一组包。优先 uv pip install回退 sys pip。
逐个安装,任意一个失败不影响后续包。
Returns:
True if all succeeded, False otherwise.
"""
if not packages:
return True
is_chinese = _is_chinese_locale()
use_uv = _has_uv()
failed: List[str] = []
for pkg in packages:
action_word = "升级" if upgrade else "安装"
if label:
print_status(f"[{label}] 正在{action_word} {pkg}...", "info")
else:
print_status(f"正在{action_word} {pkg}...", "info")
if use_uv:
cmd = ["uv", "pip", "install"]
if upgrade:
cmd.append("--upgrade")
cmd.append(pkg)
if is_chinese:
cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
else:
cmd = [sys.executable, "-m", "pip", "install"]
if upgrade:
cmd.append("--upgrade")
cmd.append(pkg)
if is_chinese:
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0:
installer = "uv" if use_uv else "pip"
print_status(f"{pkg} {action_word}成功 (via {installer})", "success")
else:
stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error"
print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error")
failed.append(pkg)
except subprocess.TimeoutExpired:
print_status(f"× {pkg} {action_word}超时 (300s)", "error")
failed.append(pkg)
except Exception as e:
print_status(f"× {pkg} {action_word}异常: {e}", "error")
failed.append(pkg)
if failed:
print_status(f"{len(failed)} 个包操作失败: {', '.join(failed)}", "error")
return False
return True
# ---------------------------------------------------------------------------
# requirements.txt 安装(可多次调用)
# ---------------------------------------------------------------------------
def install_requirements_txt(req_path: str | Path, label: str = "") -> bool:
"""
读取一个 requirements.txt 文件,检查缺失的包并安装。
Args:
req_path: requirements.txt 文件路径
label: 日志前缀标签(如 "device_package_sim"
Returns:
True if all ok, False if any install failed.
"""
req_path = Path(req_path)
if not req_path.exists():
return True
tag = label or req_path.parent.name
print_status(f"[{tag}] 检查依赖: {req_path}", "info")
reqs: List[str] = []
with open(req_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and not line.startswith("-"):
reqs.append(line)
if not reqs:
return True
missing: List[str] = []
for req in reqs:
pkg_import = req.split(">=")[0].split("==")[0].split("<")[0].split("[")[0].split(">")[0].strip()
pkg_import = pkg_import.replace("-", "_")
try:
importlib.import_module(pkg_import)
except ImportError:
missing.append(req)
if not missing:
print_status(f"[{tag}] ✓ 依赖检查通过 ({len(reqs)} 个包)", "success")
return True
print_status(f"[{tag}] 缺失 {len(missing)} 个依赖: {', '.join(missing)}", "warning")
return _install_packages(missing, label=tag)
def check_device_package_requirements(devices_dirs: list[str]) -> bool:
"""
检查 --devices 指定的所有外部设备包目录中的 requirements.txt。
对每个目录查找 requirements.txt先在目录内找再在父目录找
"""
if not devices_dirs:
return True
all_ok = True
for d in devices_dirs:
d_path = Path(d).resolve()
req_file = d_path / "requirements.txt"
if not req_file.exists():
req_file = d_path.parent / "requirements.txt"
if not req_file.exists():
continue
if not install_requirements_txt(req_file, label=d_path.name):
all_ok = False
return all_ok
# ---------------------------------------------------------------------------
# UniLabOS 核心环境检查
# ---------------------------------------------------------------------------
class EnvironmentChecker:
"""环境检查器"""
def __init__(self):
# 定义必需的包及其安装名称的映射
self.required_packages = {
# 包导入名 : pip安装名
# "pymodbus.framer.FramerType": "pymodbus==3.9.2",
"websockets": "websockets",
"msgcenterpy": "msgcenterpy",
"orjson": "orjson",
@@ -28,33 +188,17 @@ class EnvironmentChecker:
"crcmod": "crcmod-plus",
}
# 特殊安装包(需要特殊处理的包)
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"}
# 包版本要求(包名: 最低版本)
self.version_requirements = {
"msgcenterpy": "0.1.8", # msgcenterpy 最低版本要求
"msgcenterpy": "0.1.8",
}
self.missing_packages = []
self.failed_installs = []
self.packages_need_upgrade = []
# 检测系统语言
self.is_chinese = self._is_chinese_locale()
def _is_chinese_locale(self) -> bool:
"""检测系统是否为中文环境"""
try:
lang = locale.getdefaultlocale()[0]
if lang and ("zh" in lang.lower() or "chinese" in lang.lower()):
return True
except Exception:
pass
return False
self.missing_packages: List[tuple] = []
self.failed_installs: List[tuple] = []
self.packages_need_upgrade: List[tuple] = []
def check_package_installed(self, package_name: str) -> bool:
"""检查包是否已安装"""
try:
importlib.import_module(package_name)
return True
@@ -62,7 +206,6 @@ class EnvironmentChecker:
return False
def get_package_version(self, package_name: str) -> str | None:
"""获取已安装包的版本"""
try:
module = importlib.import_module(package_name)
return getattr(module, "__version__", None)
@@ -70,88 +213,32 @@ class EnvironmentChecker:
return None
def compare_version(self, current: str, required: str) -> bool:
"""
比较版本号
Returns:
True: current >= required
False: current < required
"""
try:
current_parts = [int(x) for x in current.split(".")]
required_parts = [int(x) for x in required.split(".")]
# 补齐长度
max_len = max(len(current_parts), len(required_parts))
current_parts.extend([0] * (max_len - len(current_parts)))
required_parts.extend([0] * (max_len - len(required_parts)))
return current_parts >= required_parts
except Exception:
return True # 如果无法比较,假设版本满足要求
def install_package(self, package_name: str, pip_name: str, upgrade: bool = False) -> bool:
"""安装包"""
try:
action = "升级" if upgrade else "安装"
print_status(f"正在{action} {package_name} ({pip_name})...", "info")
# 构建安装命令
cmd = [sys.executable, "-m", "pip", "install"]
# 如果是升级操作,添加 --upgrade 参数
if upgrade:
cmd.append("--upgrade")
cmd.append(pip_name)
# 如果是中文环境,使用清华镜像源
if self.is_chinese:
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
# 执行安装
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) # 5分钟超时
if result.returncode == 0:
print_status(f"{package_name} {action}成功", "success")
return True
else:
print_status(f"× {package_name} {action}失败: {result.stderr}", "error")
return False
except subprocess.TimeoutExpired:
print_status(f"× {package_name} {action}超时", "error")
return False
except Exception as e:
print_status(f"× {package_name} {action}异常: {str(e)}", "error")
return False
def upgrade_package(self, package_name: str, pip_name: str) -> bool:
"""升级包"""
return self.install_package(package_name, pip_name, upgrade=True)
return True
def check_all_packages(self) -> bool:
"""检查所有必需的包"""
print_status("开始检查环境依赖...", "info")
# 检查常规包
for import_name, pip_name in self.required_packages.items():
if not self.check_package_installed(import_name):
self.missing_packages.append((import_name, pip_name))
else:
# 检查版本要求
if import_name in self.version_requirements:
current_version = self.get_package_version(import_name)
required_version = self.version_requirements[import_name]
elif import_name in self.version_requirements:
current_version = self.get_package_version(import_name)
required_version = self.version_requirements[import_name]
if current_version and not self.compare_version(current_version, required_version):
print_status(
f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})",
"warning",
)
self.packages_need_upgrade.append((import_name, pip_name))
if current_version:
if not self.compare_version(current_version, required_version):
print_status(
f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})",
"warning",
)
self.packages_need_upgrade.append((import_name, pip_name))
# 检查特殊包
for package_name, install_url in self.special_packages.items():
if not self.check_package_installed(package_name):
self.missing_packages.append((package_name, install_url))
@@ -170,7 +257,6 @@ class EnvironmentChecker:
return False
def install_missing_packages(self, auto_install: bool = True) -> bool:
"""安装缺失的包"""
if not self.missing_packages and not self.packages_need_upgrade:
return True
@@ -178,62 +264,36 @@ class EnvironmentChecker:
if self.missing_packages:
print_status("缺失以下包:", "warning")
for import_name, pip_name in self.missing_packages:
print_status(f" - {import_name} (pip install {pip_name})", "warning")
print_status(f" - {import_name} ({pip_name})", "warning")
if self.packages_need_upgrade:
print_status("需要升级以下包:", "warning")
for import_name, pip_name in self.packages_need_upgrade:
print_status(f" - {import_name} (pip install --upgrade {pip_name})", "warning")
print_status(f" - {import_name} ({pip_name})", "warning")
return False
# 安装缺失的包
if self.missing_packages:
print_status(f"开始自动安装 {len(self.missing_packages)} 个缺失的包...", "info")
pkgs = [pip_name for _, pip_name in self.missing_packages]
if not _install_packages(pkgs, label="unilabos"):
self.failed_installs.extend(self.missing_packages)
success_count = 0
for import_name, pip_name in self.missing_packages:
if self.install_package(import_name, pip_name):
success_count += 1
else:
self.failed_installs.append((import_name, pip_name))
print_status(f"✓ 成功安装 {success_count}/{len(self.missing_packages)} 个包", "success")
# 升级需要更新的包
if self.packages_need_upgrade:
print_status(f"开始自动升级 {len(self.packages_need_upgrade)} 个包...", "info")
pkgs = [pip_name for _, pip_name in self.packages_need_upgrade]
if not _install_packages(pkgs, upgrade=True, label="unilabos"):
self.failed_installs.extend(self.packages_need_upgrade)
upgrade_success_count = 0
for import_name, pip_name in self.packages_need_upgrade:
if self.upgrade_package(import_name, pip_name):
upgrade_success_count += 1
else:
self.failed_installs.append((import_name, pip_name))
print_status(f"✓ 成功升级 {upgrade_success_count}/{len(self.packages_need_upgrade)} 个包", "success")
if self.failed_installs:
print_status(f"{len(self.failed_installs)} 个包操作失败:", "error")
for import_name, pip_name in self.failed_installs:
print_status(f" - {import_name} ({pip_name})", "error")
return False
return True
return not self.failed_installs
def verify_installation(self) -> bool:
"""验证安装结果"""
if not self.missing_packages and not self.packages_need_upgrade:
return True
print_status("验证安装结果...", "info")
failed_verification = []
# 验证新安装的包
for import_name, pip_name in self.missing_packages:
if not self.check_package_installed(import_name):
failed_verification.append((import_name, pip_name))
# 验证升级的包
for import_name, pip_name in self.packages_need_upgrade:
if not self.check_package_installed(import_name):
failed_verification.append((import_name, pip_name))
@@ -270,17 +330,14 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b
"""
checker = EnvironmentChecker()
# 检查包
if checker.check_all_packages():
return True
# 安装缺失的包
if not checker.install_missing_packages(auto_install):
if show_details:
print_status("请手动安装缺失的包后重新启动程序", "error")
return False
# 验证安装
if not checker.verify_installation():
if show_details:
print_status("安装验证失败,请检查网络连接或手动安装", "error")
@@ -290,14 +347,12 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b
if __name__ == "__main__":
# 命令行参数解析
parser = argparse.ArgumentParser(description="UniLabOS 环境依赖检查工具")
parser.add_argument("--no-auto-install", action="store_true", help="仅检查环境,不自动安装缺失的包")
parser.add_argument("--silent", action="store_true", help="静默模式,不显示详细信息")
args = parser.parse_args()
# 执行环境检查
auto_install = not args.no_auto_install
show_details = not args.silent

View File

@@ -0,0 +1,385 @@
import logging
import os
import platform
from datetime import datetime
import ctypes
import atexit
import inspect
from typing import Tuple, cast
# 添加TRACE级别到logging模块
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
class CustomRecord:
custom_stack_info: Tuple[str, int, str, str]
# Windows颜色支持
if platform.system() == "Windows":
# 尝试启用Windows终端的ANSI支持
kernel32 = ctypes.windll.kernel32
# 获取STD_OUTPUT_HANDLE
STD_OUTPUT_HANDLE = -11
# 启用ENABLE_VIRTUAL_TERMINAL_PROCESSING
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
# 获取当前控制台模式
handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
mode = ctypes.c_ulong()
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
# 启用ANSI处理
kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
# 程序退出时恢复控制台设置
@atexit.register
def reset_console():
kernel32.SetConsoleMode(handle, mode.value)
# 定义不同日志级别的颜色
class ColoredFormatter(logging.Formatter):
"""自定义日志格式化器,支持颜色输出"""
# ANSI 颜色代码
COLORS = {
"RESET": "\033[0m", # 重置
"BOLD": "\033[1m", # 加粗
"GRAY": "\033[37m", # 灰色
"WHITE": "\033[97m", # 白色
"BLACK": "\033[30m", # 黑色
"TRACE_LEVEL": "\033[1;90m", # 加粗深灰色
"DEBUG_LEVEL": "\033[1;36m", # 加粗青色
"INFO_LEVEL": "\033[1;32m", # 加粗绿色
"WARNING_LEVEL": "\033[1;33m", # 加粗黄色
"ERROR_LEVEL": "\033[1;31m", # 加粗红色
"CRITICAL_LEVEL": "\033[1;35m", # 加粗紫色
"TRACE_TEXT": "\033[90m", # 深灰色
"DEBUG_TEXT": "\033[37m", # 灰色
"INFO_TEXT": "\033[97m", # 白色
"WARNING_TEXT": "\033[33m", # 黄色
"ERROR_TEXT": "\033[31m", # 红色
"CRITICAL_TEXT": "\033[35m", # 紫色
"DATE": "\033[37m", # 日期始终使用灰色
}
def __init__(self, use_colors=True):
super().__init__()
# 强制启用颜色
self.use_colors = use_colors
def format(self, record):
# 检查是否有自定义堆栈信息
if hasattr(record, "custom_stack_info") and record.custom_stack_info: # type: ignore
r = cast(CustomRecord, record)
frame_info = r.custom_stack_info
record.filename = frame_info[0]
record.lineno = frame_info[1]
record.funcName = frame_info[2]
if len(frame_info) > 3:
record.name = frame_info[3]
if not self.use_colors:
return self._format_basic(record)
level_color = self.COLORS.get(f"{record.levelname}_LEVEL", self.COLORS["WHITE"])
text_color = self.COLORS.get(f"{record.levelname}_TEXT", self.COLORS["WHITE"])
date_color = self.COLORS["DATE"]
reset = self.COLORS["RESET"]
# 日期格式
datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]"
# 模块和函数信息
filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名)
if "/" in filename:
filename = filename.split("/")[-1]
module_path = f"{record.name}.{filename}"
func_line = f"{record.funcName}:{record.lineno}"
right_info = f" [{func_line}] [{module_path}]"
# 主要消息
main_msg = record.getMessage()
# 构建基本消息格式
formatted_message = (
f"{date_color}{datetime_str}{reset} "
f"{level_color}[{record.levelname}]{reset} "
f"{text_color}{main_msg}"
f"{date_color}{right_info}{reset}"
)
# 处理异常信息
if record.exc_info:
exc_text = self.formatException(record.exc_info)
if formatted_message[-1:] != "\n":
formatted_message = formatted_message + "\n"
formatted_message = formatted_message + text_color + exc_text + reset
elif record.stack_info:
if formatted_message[-1:] != "\n":
formatted_message = formatted_message + "\n"
formatted_message = formatted_message + text_color + self.formatStack(record.stack_info) + reset
return formatted_message
def _format_basic(self, record):
"""基本格式化,不包含颜色"""
datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]"
filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名)
if "/" in filename:
filename = filename.split("/")[-1]
module_path = f"{record.name}.{filename}"
func_line = f"{record.funcName}:{record.lineno}"
right_info = f" [{func_line}] [{module_path}]"
formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}"
if record.exc_info:
exc_text = self.formatException(record.exc_info)
if formatted_message[-1:] != "\n":
formatted_message = formatted_message + "\n"
formatted_message = formatted_message + exc_text
elif record.stack_info:
if formatted_message[-1:] != "\n":
formatted_message = formatted_message + "\n"
formatted_message = formatted_message + self.formatStack(record.stack_info)
return formatted_message
def formatException(self, exc_info):
"""重写异常格式化,确保异常信息保持正确的格式和颜色"""
# 获取标准的异常格式化文本
formatted_exc = super().formatException(exc_info)
return formatted_exc
# 配置日志处理器
def configure_logger(loglevel=None, working_dir=None):
"""配置日志记录器
Args:
loglevel: 日志级别,可以是字符串('TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
或logging模块的常量如logging.DEBUG或TRACE_LEVEL
"""
# 获取根日志记录器
root_logger = logging.getLogger()
root_logger.setLevel(TRACE_LEVEL)
# 设置日志级别
numeric_level = logging.DEBUG
if loglevel is not None:
if isinstance(loglevel, str):
# 将字符串转换为logging级别
if loglevel.upper() == "TRACE":
numeric_level = TRACE_LEVEL
else:
numeric_level = getattr(logging, loglevel.upper(), None)
if not isinstance(numeric_level, int):
print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG")
else:
numeric_level = loglevel
# 移除已存在的处理器
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 创建控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(numeric_level) # 使用与根记录器相同的级别
# 使用自定义的颜色格式化器
color_formatter = ColoredFormatter()
console_handler.setFormatter(color_formatter)
# 添加处理器到根日志记录器
root_logger.addHandler(console_handler)
# 如果指定了工作目录,添加文件处理器
if working_dir is not None:
logs_dir = os.path.join(working_dir, "logs")
os.makedirs(logs_dir, exist_ok=True)
# 生成日志文件名:日期 时间.log
log_filename = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log"
log_filepath = os.path.join(logs_dir, log_filename)
# 创建文件处理器
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
file_handler.setLevel(TRACE_LEVEL)
# 使用不带颜色的格式化器
file_formatter = ColoredFormatter(use_colors=False)
file_handler.setFormatter(file_formatter)
root_logger.addHandler(file_handler)
logging.getLogger("asyncio").setLevel(logging.INFO)
logging.getLogger("urllib3").setLevel(logging.INFO)
# 配置日志系统
configure_logger()
# 获取日志记录器
logger = logging.getLogger(__name__)
# 获取调用栈信息的工具函数
def _get_caller_info(stack_level=0) -> Tuple[str, int, str, str]:
"""
获取调用者的信息
Args:
stack_level: 堆栈回溯的级别0表示当前函数1表示调用者依此类推
Returns:
(filename, line_number, function_name, module_name) 元组
"""
# 堆栈级别需要加3:
# +1 因为这个函数本身占一层
# +1 因为日志函数(debug, info等)占一层
# +1 因为下面调用 inspect.stack() 也占一层
frame = inspect.currentframe()
try:
# 跳过适当的堆栈帧
for _ in range(stack_level + 3):
if frame and frame.f_back:
frame = frame.f_back
else:
break
if frame:
filename = frame.f_code.co_filename if frame.f_code else "unknown"
line_number = frame.f_lineno if hasattr(frame, "f_lineno") else 0
function_name = frame.f_code.co_name if frame.f_code else "unknown"
# 获取模块名称
module_name = "unknown"
if frame.f_globals and "__name__" in frame.f_globals:
module_name = frame.f_globals["__name__"].rsplit(".", 1)[0]
return (filename, line_number, function_name, module_name)
return ("unknown", 0, "unknown", "unknown")
finally:
del frame # 避免循环引用
# 便捷日志记录函数
def debug(msg, *args, stack_level=0, **kwargs):
"""
记录DEBUG级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.debug的其他参数
"""
# 获取调用者信息
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.debug(msg, *args, **kwargs)
def info(msg, *args, stack_level=0, **kwargs):
"""
记录INFO级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.info的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.info(msg, *args, **kwargs)
def warning(msg, *args, stack_level=0, **kwargs):
"""
记录WARNING级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.warning的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.warning(msg, *args, **kwargs)
def error(msg, *args, stack_level=0, **kwargs):
"""
记录ERROR级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.error的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.error(msg, *args, **kwargs)
def critical(msg, *args, stack_level=0, **kwargs):
"""
记录CRITICAL级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.critical的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.critical(msg, *args, **kwargs)
def trace(msg, *args, stack_level=0, **kwargs):
"""
记录TRACE级别日志比DEBUG级别更低
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.log的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.log(TRACE_LEVEL, msg, *args, **kwargs)
logger.trace = trace
# 测试日志输出(如果直接运行此文件)
if __name__ == "__main__":
print("测试不同日志级别的颜色输出:")
trace("这是一条跟踪日志 (TRACE级别显示为深灰色其他文本也为深灰色)")
debug("这是一条调试日志 (DEBUG级别显示为蓝色其他文本为灰色)")
info("这是一条信息日志 (INFO级别显示为绿色其他文本为白色)")
warning("这是一条警告日志 (WARNING级别显示为黄色其他文本也为黄色)")
error("这是一条错误日志 (ERROR级别显示为红色其他文本也为红色)")
critical("这是一条严重错误日志 (CRITICAL级别显示为紫色其他文本也为紫色)")
# 测试异常输出
try:
1 / 0
except Exception as e:
error(f"发生错误: {e}", exc_info=True)

View File

@@ -191,9 +191,23 @@ def configure_logger(loglevel=None, working_dir=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)
# ROS 节点的状态更新日志过于频繁,设置为 INFO
logging.getLogger('unilabos.ros.nodes.presets.host_node').setLevel(logging.INFO)
# 如果指定了工作目录,添加文件处理器
log_filepath = None
if working_dir is not None:
logs_dir = os.path.join(working_dir, "logs")
os.makedirs(logs_dir, exist_ok=True)
@@ -214,7 +228,6 @@ def configure_logger(loglevel=None, working_dir=None):
logging.getLogger("asyncio").setLevel(logging.INFO)
logging.getLogger("urllib3").setLevel(logging.INFO)
return log_filepath
# 配置日志系统

View File

@@ -80,11 +80,12 @@ def get_result_info_str(error: str, suc: bool, return_value=None) -> str:
Returns:
JSON字符串格式的结果信息
"""
samples = None
if isinstance(return_value, dict):
if "samples" in return_value:
samples = return_value.pop("samples")
result_info = {"error": error, "suc": suc, "return_value": return_value, "samples": samples}
# 请在返回的字典中使用 unilabos_samples进行返回
# samples = None
# if isinstance(return_value, dict):
# if "samples" in return_value and type(return_value["samples"]) in [list, tuple] and type(return_value["samples"][0]) == dict:
# samples = return_value.pop("samples")
result_info = {"error": error, "suc": suc, "return_value": return_value}
return json.dumps(result_info, ensure_ascii=False, cls=ResultInfoEncoder)

View File

View File

@@ -346,7 +346,7 @@ def refactor_data(
"template_name": template_name,
"resource_name": resource_name,
"description": step.get("description", step.get("purpose", f"{operation} operation")),
"lab_node_type": "Device",
"lab_node_type": "ILab",
"param": step.get("parameters", step.get("action_args", {})),
"footer": f"{template_name}-{resource_name}",
}

View File

@@ -0,0 +1,241 @@
import ast
import json
from typing import Dict, List, Any, Tuple, Optional
from .common import WorkflowGraph, RegistryAdapter
Json = Dict[str, Any]
# ---------------- Converter ----------------
class DeviceMethodConverter:
"""
- 字段统一resource_name原 device_class、template_name原 action_key
- params 单层inputs 使用 'params.' 前缀
- SimpleGraph.add_workflow_node 负责变量连线与边
"""
def __init__(self, device_registry: Optional[Dict[str, Any]] = None):
self.graph = WorkflowGraph()
self.variable_sources: Dict[str, Dict[str, Any]] = {} # var -> {node_id, output_name}
self.instance_to_resource: Dict[str, Optional[str]] = {} # 实例名 -> resource_name
self.node_id_counter: int = 0
self.registry = RegistryAdapter(device_registry or {})
# ---- helpers ----
def _new_node_id(self) -> int:
nid = self.node_id_counter
self.node_id_counter += 1
return nid
def _assign_targets(self, targets) -> List[str]:
names: List[str] = []
import ast
if isinstance(targets, ast.Tuple):
for elt in targets.elts:
if isinstance(elt, ast.Name):
names.append(elt.id)
elif isinstance(targets, ast.Name):
names.append(targets.id)
return names
def _extract_device_instantiation(self, node) -> Optional[Tuple[str, str]]:
import ast
if not isinstance(node.value, ast.Call):
return None
callee = node.value.func
if isinstance(callee, ast.Name):
class_name = callee.id
elif isinstance(callee, ast.Attribute) and isinstance(callee.value, ast.Name):
class_name = callee.attr
else:
return None
if isinstance(node.targets[0], ast.Name):
instance = node.targets[0].id
return instance, class_name
return None
def _extract_call(self, call) -> Tuple[str, str, Dict[str, Any], str]:
import ast
owner_name, method_name, call_kind = "", "", "func"
if isinstance(call.func, ast.Attribute):
method_name = call.func.attr
if isinstance(call.func.value, ast.Name):
owner_name = call.func.value.id
call_kind = "instance" if owner_name in self.instance_to_resource else "class_or_module"
elif isinstance(call.func.value, ast.Attribute) and isinstance(call.func.value.value, ast.Name):
owner_name = call.func.value.attr
call_kind = "class_or_module"
elif isinstance(call.func, ast.Name):
method_name = call.func.id
call_kind = "func"
def pack(node):
if isinstance(node, ast.Name):
return {"type": "variable", "value": node.id}
if isinstance(node, ast.Constant):
return {"type": "constant", "value": node.value}
if isinstance(node, ast.Dict):
return {"type": "dict", "value": self._parse_dict(node)}
if isinstance(node, ast.List):
return {"type": "list", "value": self._parse_list(node)}
return {"type": "raw", "value": ast.unparse(node) if hasattr(ast, "unparse") else str(node)}
args: Dict[str, Any] = {}
pos: List[Any] = []
for a in call.args:
pos.append(pack(a))
for kw in call.keywords:
args[kw.arg] = pack(kw.value)
if pos:
args["_positional"] = pos
return owner_name, method_name, args, call_kind
def _parse_dict(self, node) -> Dict[str, Any]:
import ast
out: Dict[str, Any] = {}
for k, v in zip(node.keys, node.values):
if isinstance(k, ast.Constant):
key = str(k.value)
if isinstance(v, ast.Name):
out[key] = f"var:{v.id}"
elif isinstance(v, ast.Constant):
out[key] = v.value
elif isinstance(v, ast.Dict):
out[key] = self._parse_dict(v)
elif isinstance(v, ast.List):
out[key] = self._parse_list(v)
return out
def _parse_list(self, node) -> List[Any]:
import ast
out: List[Any] = []
for elt in node.elts:
if isinstance(elt, ast.Name):
out.append(f"var:{elt.id}")
elif isinstance(elt, ast.Constant):
out.append(elt.value)
elif isinstance(elt, ast.Dict):
out.append(self._parse_dict(elt))
elif isinstance(elt, ast.List):
out.append(self._parse_list(elt))
return out
def _normalize_var_tokens(self, x: Any) -> Any:
if isinstance(x, str) and x.startswith("var:"):
return {"__var__": x[4:]}
if isinstance(x, list):
return [self._normalize_var_tokens(i) for i in x]
if isinstance(x, dict):
return {k: self._normalize_var_tokens(v) for k, v in x.items()}
return x
def _make_params_payload(self, resource_name: Optional[str], template_name: str, call_args: Dict[str, Any]) -> Dict[str, Any]:
input_keys = self.registry.get_action_input_keys(resource_name, template_name) if resource_name else []
defaults = self.registry.get_action_goal_default(resource_name, template_name) if resource_name else {}
params: Dict[str, Any] = dict(defaults)
def unpack(p):
t, v = p.get("type"), p.get("value")
if t == "variable":
return {"__var__": v}
if t == "dict":
return self._normalize_var_tokens(v)
if t == "list":
return self._normalize_var_tokens(v)
return v
for k, p in call_args.items():
if k == "_positional":
continue
params[k] = unpack(p)
pos = call_args.get("_positional", [])
if pos:
if input_keys:
for i, p in enumerate(pos):
if i >= len(input_keys):
break
name = input_keys[i]
if name in params:
continue
params[name] = unpack(p)
else:
for i, p in enumerate(pos):
params[f"arg_{i}"] = unpack(p)
return params
# ---- handlers ----
def _on_assign(self, stmt):
import ast
inst = self._extract_device_instantiation(stmt)
if inst:
instance, code_class = inst
resource_name = self.registry.resolve_resource_by_classname(code_class)
self.instance_to_resource[instance] = resource_name
return
if isinstance(stmt.value, ast.Call):
owner, method, call_args, kind = self._extract_call(stmt.value)
if kind == "instance":
device_key = owner
resource_name = self.instance_to_resource.get(owner)
else:
device_key = owner
resource_name = self.registry.resolve_resource_by_classname(owner)
module = self.registry.get_device_module(resource_name)
params = self._make_params_payload(resource_name, method, call_args)
nid = self._new_node_id()
self.graph.add_workflow_node(
nid,
device_key=device_key,
resource_name=resource_name, # ✅
module=module,
template_name=method, # ✅
params=params,
variable_sources=self.variable_sources,
add_ready_if_no_vars=True,
prev_node_id=(nid - 1) if nid > 0 else None,
)
out_vars = self._assign_targets(stmt.targets[0])
for var in out_vars:
self.variable_sources[var] = {"node_id": nid, "output_name": "result"}
def _on_expr(self, stmt):
import ast
if not isinstance(stmt.value, ast.Call):
return
owner, method, call_args, kind = self._extract_call(stmt.value)
if kind == "instance":
device_key = owner
resource_name = self.instance_to_resource.get(owner)
else:
device_key = owner
resource_name = self.registry.resolve_resource_by_classname(owner)
module = self.registry.get_device_module(resource_name)
params = self._make_params_payload(resource_name, method, call_args)
nid = self._new_node_id()
self.graph.add_workflow_node(
nid,
device_key=device_key,
resource_name=resource_name, # ✅
module=module,
template_name=method, # ✅
params=params,
variable_sources=self.variable_sources,
add_ready_if_no_vars=True,
prev_node_id=(nid - 1) if nid > 0 else None,
)
def convert(self, python_code: str):
tree = ast.parse(python_code)
for stmt in tree.body:
if isinstance(stmt, ast.Assign):
self._on_assign(stmt)
elif isinstance(stmt, ast.Expr):
self._on_expr(stmt)
return self

View File

@@ -0,0 +1,131 @@
from typing import List, Any, Dict
import xml.etree.ElementTree as ET
def convert_to_type(val: str) -> Any:
"""将字符串值转换为适当的数据类型"""
if val == "True":
return True
if val == "False":
return False
if val == "?":
return None
if val.endswith(" g"):
return float(val.split(" ")[0])
if val.endswith("mg"):
return float(val.split("mg")[0])
elif val.endswith("mmol"):
return float(val.split("mmol")[0]) / 1000
elif val.endswith("mol"):
return float(val.split("mol")[0])
elif val.endswith("ml"):
return float(val.split("ml")[0])
elif val.endswith("RPM"):
return float(val.split("RPM")[0])
elif val.endswith(" °C"):
return float(val.split(" ")[0])
elif val.endswith(" %"):
return float(val.split(" ")[0])
return val
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
"""展平嵌套的XDL程序结构"""
flattened_operations = []
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
def extract_operations(element: ET.Element):
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
flattened_operations.append(element)
for child in element:
extract_operations(child)
for child in procedure_elem:
extract_operations(child)
return flattened_operations
def parse_xdl_content(xdl_content: str) -> tuple:
"""解析XDL内容"""
try:
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
root = ET.fromstring(xdl_content_cleaned)
synthesis_elem = root.find("Synthesis")
if synthesis_elem is None:
return None, None, None
# 解析硬件组件
hardware_elem = synthesis_elem.find("Hardware")
hardware = []
if hardware_elem is not None:
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
# 解析试剂
reagents_elem = synthesis_elem.find("Reagents")
reagents = []
if reagents_elem is not None:
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
# 解析程序
procedure_elem = synthesis_elem.find("Procedure")
if procedure_elem is None:
return None, None, None
flattened_operations = flatten_xdl_procedure(procedure_elem)
return hardware, reagents, flattened_operations
except ET.ParseError as e:
raise ValueError(f"Invalid XDL format: {e}")
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
"""
将XDL XML格式转换为标准的字典格式
Args:
xdl_content: XDL XML内容
Returns:
转换结果,包含步骤和器材信息
"""
try:
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
if hardware is None:
return {"error": "Failed to parse XDL content", "success": False}
# 将XDL元素转换为字典格式
steps_data = []
for elem in flattened_operations:
# 转换参数类型
parameters = {}
for key, val in elem.attrib.items():
converted_val = convert_to_type(val)
if converted_val is not None:
parameters[key] = converted_val
step_dict = {
"operation": elem.tag,
"parameters": parameters,
"description": elem.get("purpose", f"Operation: {elem.tag}"),
}
steps_data.append(step_dict)
# 合并硬件和试剂为统一的labware_info格式
labware_data = []
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
return {
"success": True,
"steps": steps_data,
"labware": labware_data,
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
}
except Exception as e:
error_msg = f"XDL conversion failed: {str(e)}"
return {"error": error_msg, "success": False}