Compare commits

..

164 Commits

Author SHA1 Message Date
q434343
acb2dc9359 初始化完成后上传更新deck 2026-04-14 16:57:33 +08:00
q434343
f22c3f4c42 修改移液部分代码,如果没有10ul枪头,则不使用10ul移液枪 2026-04-14 15:35:04 +08:00
q434343
7df67ea9f3 Merge branch 'dev' into prcix9320 2026-04-09 18:12:25 +08:00
q434343
4d3a41ed0d 修改部分移液逻辑 2026-04-09 18:06:12 +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
Xuwznln
1d1c1367df scale multi exec thread up to 48 2026-04-09 14:15:38 +08:00
q434343
56d25b88bd 使用16个孔与固定tip头类型,来定位slot位置 2026-04-08 02:51:28 +08:00
q434343
95f3e0b291 修改物料位置与初始化位置计算方法 2026-04-03 17:10:14 +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
q434343
9b706236f6 完成物料位置标定 2026-04-02 12:44:33 +08:00
q434343
9f60e65b6d Merge pull request #255 from ALITTLELZ/labwaresize
新增 tip_above_rack_length 参数并更新枪头尺寸
2026-04-01 18:33:59 +08:00
ALITTLELZ
59aa991988 新增 tip_above_rack_length 参数并更新 PRCXI 枪头尺寸
- TipInfo 新增 tip_above_rack_length 可选字段
- 编辑器支持 tip_above 与 dz 互算,更新中文标签
- 侧视图绘制枪头露出部分并标注,俯视图/侧视图增加 dx/dy/dz 标注
- 预览增加回中按钮,详情页展示新字段
- 导入时自动计算 tip_above_rack_length
- 批量更新 PRCXI 枪头物理尺寸及 registry YAML

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:22:23 +08:00
q434343
aff340de84 Merge pull request #254 from ALITTLELZ/labwaresize
添加 PRCXI 耗材管理 Web 应用 (labware_manager)
2026-04-01 17:03:20 +08:00
Xuwznln
25c94af755 add running status debounce 2026-04-01 16:01:22 +08:00
ALITTLELZ
2fd4270831 添加 PRCXI 耗材管理 Web 应用 (labware_manager)
新增 labware_manager 模块:
- Web UI 支持耗材 CRUD、SVG 俯视图/侧面图实时预览
- SVG 支持触控板双指缩放(pinch-to-zoom)和平移
- 网格排列自动居中按钮(autoCenter)
- 表单参数标签中英文双语显示
- 从已有代码/YAML 导入、Python/YAML 代码生成

更新 CLAUDE.md:补充 labware manager、decorator 注册模式、CI 说明

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:19:52 +08:00
q434343
0d41d83ce5 Merge branch 'feat/lab_resource' into prcix9320 2026-04-01 11:55:01 +08:00
q434343
9a6f744afd Merge branch 'sjs_middle_school' into feat/lab_resource 2026-04-01 11:51:54 +08:00
q434343
8164d990cc 适配前吸空气部分 2026-04-01 11:50:42 +08:00
q434343
5c9c8a4ee9 Merge branch 'prcix9320' into sjs_middle_school 2026-03-31 18:48:20 +08:00
q434343
68ef739f4a Merge pull request #253 from ALITTLELZ/9300
Add PRCXI 9300 (3x2) deck layout support
2026-03-31 17:28:52 +08:00
ALITTLELZ
29a484f16f Add "trash" to site content_type in Deck and experiment JSONs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:08:20 +08:00
q434343
a48985720c 添加run_protocol参数 2026-03-31 16:11:11 +08:00
ALITTLELZ
14cf4ddc0d Add PRCXI 9300 (3x2) deck layout support via model parameter
PRCXI9300Deck now accepts model="9300"|"9320" to auto-select 6-slot or
16-slot layout. DefaultLayout gains default_layout for 9300 with T6 as
trash. PRCXI9300Handler auto-derives is_9320 from deck.model when not
explicitly passed. Includes 9300 slim experiment JSON and test fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:00:26 +08:00
q434343
ad66fc1841 其他修改, 2026-03-31 14:57:51 +08:00
q434343
6b3f9756a0 修改真机运动方式, 2026-03-31 14:33:50 +08:00
q434343
afddc6e40c 修改上传工作流部分代码 2026-03-31 14:32:48 +08:00
Xuwznln
861a012747 allow non @topic_config support 2026-03-31 13:15:06 +08:00
q434343
edd67e4880 Merge branch 'dev' into feat/lab_resource 2026-03-27 19:50:51 +08:00
q434343
d13d3f7dfe Merge pull request #250 from ALITTLELZ/adaptors
Add PRCXI functional modules and fix Deck layout
2026-03-26 12:27:06 +08:00
Xuwznln
ee63e95f50 update skill 2026-03-25 23:20:13 +08:00
q434343
1ab1ed69d4 Merge branch 'dev' into feat/lab_resource 2026-03-25 19:48:39 +08:00
q434343
ad2e5a1c04 更新prcxi的版面更新与工作流上传方法 2026-03-25 19:42:25 +08:00
ALITTLELZ
71d35d31af Register PRCXI9300ModuleSite/FunctionalModule for PLR deserialization
Added PRCXI9300ModuleSite and PRCXI9300FunctionalModule to the PLR
class registration in plr_additional_res_reg.py so find_subclass can
locate them during deserialization of cached cloud data. Also added
"module" and "carrier" to replace_plr_type and TYPE_MAP in
resource_tracker.py to suppress unknown type warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 18:32:01 +08:00
ALITTLELZ
7f4b57f589 Fix Deck slot Y-axis inversion: T1 should be top-left, not bottom-left
Upstream rewrite of PRCXI9300Deck lost the Y-axis flip logic from the
original `(3-row)*96+13` formula. T1-T4 were rendered at the bottom
instead of the top. Reversed _DEFAULT_SITE_POSITIONS Y coordinates and
updated prcxi_9320_slim.json accordingly. Also added "plateadapter" and
"module" to slim JSON content_type entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 17:16:04 +08:00
ALITTLELZ
0c667e68e6 Remove deprecated PRCXI9300PlateAdapterSite, replaced by PRCXI9300ModuleSite
PRCXI9300PlateAdapterSite was already removed by upstream/prcix9320.
Its functionality is now provided by PRCXI9300ModuleSite which serves
as the base class for functional modules (heating/cooling/shaking/magnetic).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:22:39 +08:00
ALITTLELZ
9430be51a4 Merge remote-tracking branch 'upstream/prcix9320' into adaptors
# Conflicts:
#	unilabos/devices/liquid_handling/prcxi/prcxi.py
2026-03-25 16:04:17 +08:00
ALITTLELZ
a187a57430 Add PRCXI functional modules (heating/cooling/shaking/magnetic) and registry config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:19:48 +08:00
q434343
68029217de Merge branch 'dev' into prcix9320 2026-03-25 14:44:52 +08:00
q434343
792504e08c Update .gitignore 2026-03-25 14:39:02 +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
q434343
04c0564366 Update .gitignore 2026-03-25 06:04:36 +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
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
q434343
9d65718f37 Merge branch 'prcix9320' into feat/lab_resource 2026-03-23 00:38:40 +08:00
q434343
35bcf6765d 修改rviz显示逻辑与joint_publisher,添加moveit2相关节点描述 2026-03-23 00:00:57 +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
q434343
cdbca70222 修改workflow上传逻辑,在trash初始化后再开始移液,修改枪头pick和drop的判断 2026-03-19 02:35:25 +08:00
q434343
1a267729e4 修改tracker的问题,在volumetracker报错时,禁用并进行下一步 2026-03-17 20:29:15 +08:00
q434343
b11f6eac55 修改pylabrobot更新后的影响 2026-03-16 20:42:13 +08:00
q434343
d85ff540c4 完成mix,liquid_hight,touch_tip,delay等参数的传递 2026-03-12 13:58:06 +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
q434343
5f45a0b81b 修改transfer liquid方法 2026-03-09 19:48:57 +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
6bf9a319c7 Merge branch 'dev' into feat/lab_resource 2026-03-03 18:05:43 +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
q434343
74f0d5ee65 Merge branch 'feat/lab_resource' of https://github.com/deepmodeling/Uni-Lab-OS into feat/lab_resource 2026-03-03 14:17:36 +08:00
Xuwznln
7f1cc3b2a5 update materials 2026-03-03 11:43:52 +08:00
Xuwznln
2596d48a2f update materials 2026-03-03 11:43:41 +08:00
Xuwznln
3f160c2049 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:23 +08:00
Xuwznln
2ac1a3242a 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:02 +08:00
q434343
5d208c832b 修改工作流上传以及lh的物料初步判定 2026-03-02 18:32:44 +08:00
q434343
786498904d 修改上传方式,添加tip_rack的连线 2026-03-02 18:32:18 +08:00
q434343
a9ea9f425d 添加单枪头的多对多移液判定 2026-03-02 18:31:28 +08:00
Xuwznln
b3bc951cae registry update & workflow update 2026-03-02 18:31:26 +08:00
Xuwznln
01df4f1115 add resource 2026-03-02 18:30:07 +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
ALITTLELZ
ca985f92ab Add 'plateadapter' to device and test configurations 2026-03-02 14:35:12 +08:00
Xuwznln
514373c164 v0.10.18
(cherry picked from commit 06b6f0d804)
2026-03-02 02:30:10 +08:00
q434343
41be9e4e19 merge lab_resource,并修改transfer liquid 2026-02-28 16:05:29 +08:00
Xuwznln
fcea02585a no opcua installation on macos 2026-02-28 09:41:37 +08:00
q434343
e1074f06d2 修改工作流上传以及lh的物料初步判定 2026-02-26 10:52:41 +08:00
q434343
0dc273f366 修改上传方式,添加tip_rack的连线 2026-02-24 19:37:11 +08:00
q434343
2e5fac26b3 添加单枪头的多对多移液判定 2026-02-13 13:46:27 +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
5c2da9b793 fix possible crash 2026-02-11 23:44:53 +08:00
Xuwznln
45efbfcd12 fix deck & host_node 2026-02-11 17:33:26 +08:00
Xuwznln
8da6fdfd0b set liquid with tube 2026-02-11 16:20:07 +08:00
Xuwznln
29ea9909a5 Merge branch 'dev' into feat/lab_resource 2026-02-11 14:04:49 +08:00
Xuwznln
f9ed6cb3fb add test_resource_schema 2026-02-11 14:02:21 +08:00
q434343
f38f3dfc89 Merge branch 'dev' into prcix9320 2026-02-11 11:44:15 +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
ee6307a568 registry update & workflow update 2026-02-10 22:45:51 +08:00
Xuwznln
8a0116c852 add resource 2026-02-10 22:44:45 +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
q434343
d3f59913b0 Merge branch 'dev' into prcix9320 2026-02-10 14:15:36 +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
q434343
f6d46e669d Merge branch 'dev' into prcix9320 2026-02-06 15:14:59 +08:00
q434343
abf5555e37 修改添加step mode 2026-02-06 15:12:56 +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
q434343
e4d915c59c Merge branch 'dev' into prcix9320 2026-02-04 15:46:16 +08:00
q434343
11a38d4558 添加加热震荡模块与磁力模块 2026-02-04 15:28:23 +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
167 changed files with 43299 additions and 10092 deletions

View File

@@ -3,7 +3,7 @@
package:
name: unilabos
version: 0.10.17
version: 0.10.19
source:
path: ../../unilabos
@@ -46,13 +46,15 @@ requirements:
- jinja2
- requests
- uvicorn
- opcua
- if: not osx
then:
- opcua
- pyserial
- pandas
- pymodbus
- matplotlib
- pylibftdi
- uni-lab::unilabos-env ==0.10.17
- uni-lab::unilabos-env ==0.10.19
about:
repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

@@ -2,7 +2,7 @@
package:
name: unilabos-env
version: 0.10.17
version: 0.10.19
build:
noarch: generic

View File

@@ -3,7 +3,7 @@
package:
name: unilabos-full
version: 0.10.17
version: 0.10.19
build:
noarch: generic
@@ -11,7 +11,7 @@ build:
requirements:
run:
# Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.10.17
- uni-lab::unilabos ==0.10.19
# Documentation tools
- sphinx
- sphinx_rtd_theme

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

@@ -0,0 +1,380 @@
---
name: create-device-skill
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
---
# 创建设备 Skill 指南
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
## 数据源
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
## 创建流程
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
#### 必备项 ①ak / sk认证凭据
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`
获取后立即生成 AUTH token
```bash
python ./scripts/gen_auth.py <ak> <sk>
# 或从 config.py 提取
python ./scripts/gen_auth.py --config <config.py>
```
认证算法:`base64(ak:sk)``Authorization: Lab <token>`
#### 必备项 ②:--addr目标环境
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
| `--addr` 值 | BASE URL |
|-------------|----------|
| `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` |
| 其他自定义 URL | 直接使用该 URL |
#### 必备项 ③req_device_registry_upload.json设备注册表
数据文件由 `unilab` 启动时自动生成,需要定位它:
**推断 working_dir**(即 `unilabos_data` 所在目录):
| 条件 | working_dir 取值 |
|------|------------------|
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
**按优先级搜索文件**
```
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
<推断的 working_dir>/req_device_registry_upload.json
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
```
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
#### 必备项 ④:目标设备
用户需要明确要为哪个设备创建 skill。可以是设备名称如「PRCXI 移液站」)或 device_id`liquid_handler.prcxi`)。
如果用户不确定,运行提取脚本列出所有设备供选择:
```bash
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
```
#### 完整示例
用户提供:
```
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
--addr test
--port 8003
--disable_browser
```
从中提取:
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
**四项全部就绪后才进入 Step 1。**
### Step 1 — 列出可用设备
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
```bash
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
python ./scripts/extract_device_actions.py
# 指定注册表文件路径
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
```
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
### Step 2 — 提取 Action Schema
用户选择设备后,运行提取脚本:
```bash
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
```
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
每个 action 生成一个 JSON 文件,包含:
- `type` — 作为 API 调用的 `action_type`
- `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义)
- `goal` — goal 字段映射(含占位符 `$placeholder`
- `goal_default` — 默认值
### Step 3 — 写 action-index.md
按模板为每个 action 写条目:
```markdown
### `<action_name>`
<用途描述(一句话)>
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
- **可选参数**: `param3`, `param4`
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
```
描述规则:
-`schema.properties` 读参数列表schema 已提升为 goal 内容)
-`schema.required` 区分核心/可选参数
- 按功能分类(移液、枪头、外设等)
- 标注 `placeholder_keys` 中的字段类型:
- `unilabos_resources`**ResourceSlot**,填入 `{id, name, uuid}`id 是路径格式,从资源树取物料节点)
- `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 模板,修改:
- 设备名称
- Action 数量
- 目录列表
- Session state 中的 `device_name`
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot物料/设备/节点/类名)
API 模板结构:
```markdown
## 设备信息
- device_id, Python 源码路径, 设备类名
## 前置条件(缺一不可)
- ak/sk → AUTH, --addr → BASE URL
## 请求约定
- Windows 平台必须用 curl.exe非 PowerShell 的 curl 别名)
## 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 字段、类型及含义
## 渐进加载策略
## 完整工作流 Checklist
```
### Step 5 — 验证
检查文件完整性:
- [ ] `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` 字段
- [ ] 描述能让 agent 判断该用哪个 action
## Action JSON 文件结构
```json
{
"type": "LiquidHandlerTransfer", // → API 的 action_type
"goal": { // goal 字段映射
"sources": "sources",
"targets": "targets",
"tip_racks": "tip_racks",
"asp_vols": "asp_vols"
},
"schema": { // ← 直接是 goal 的 schema已提升
"type": "object",
"properties": { // 参数定义(即请求中 goal 的字段)
"sources": { "type": "array", "items": { "type": "object" } },
"targets": { "type": "array", "items": { "type": "object" } },
"asp_vols": { "type": "array", "items": { "type": "number" } }
},
"required": [...],
"_unilabos_placeholder_info": { // ← Slot 类型标记
"sources": "unilabos_resources",
"targets": "unilabos_resources",
"tip_racks": "unilabos_resources"
}
},
"goal_default": { ... }, // 默认值
"placeholder_keys": { // ← 汇总所有 Slot 字段
"sources": "unilabos_resources", // ResourceSlot
"targets": "unilabos_resources",
"tip_racks": "unilabos_resources",
"target_device_id": "unilabos_devices" // DeviceSlot
}
}
```
> **注意**`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段PATCH 更新时直接修改 `param` 即可。
## Placeholder Slot 类型体系
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|---------------|-----------|---------|---------|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
| `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`
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
```json
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
```
- 单个schema type=object`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
- 数组schema type=array`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 本身是从 parent 计算的路径格式
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
> **特例**`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
### DeviceSlot`unilabos_devices`
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
```
"/host_node"
"/bioyond_cell/reaction_station"
```
- 只填路径字符串,不需要 `{id, uuid}` 对象
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
### NodeSlot`unilabos_nodes`
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**
```
"/PRCXI/PRCXI_Deck"
```
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol``from_vessel`/`to_vessel``create_resource``parent`
### ClassSlot`unilabos_class`
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
```
"container"
```
### 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"
```
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name``uuid``type``parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
## 最终目录结构
```
./<skill-name>/
├── SKILL.md # API 端点 + 渐进加载指引
├── action-index.md # 动作索引:描述/用途/核心参数
└── actions/ # 每个 action 的完整 JSON Schema
├── action1.json
├── action2.json
└── ...
```

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
从 req_device_registry_upload.json 中提取指定设备的 action schema。
用法:
# 列出所有设备及 action 数量(自动搜索注册表文件)
python extract_device_actions.py
# 指定注册表文件路径
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
# 提取指定设备的 action 到目录
python extract_device_actions.py <device_id> <output_dir>
python extract_device_actions.py --registry <path> <device_id> <output_dir>
示例:
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
"""
import json
import os
import sys
from datetime import datetime
REGISTRY_FILENAME = "req_device_registry_upload.json"
def find_registry(explicit_path=None):
"""
查找 req_device_registry_upload.json 文件。
搜索优先级:
1. 用户通过 --registry 显式指定的路径
2. <cwd>/unilabos_data/req_device_registry_upload.json
3. <cwd>/req_device_registry_upload.json
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
5. 向上逐级搜索父目录(最多 5 层)
"""
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 list_devices(data):
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
resources = data.get('resources', [])
devices = []
for res in resources:
rid = res.get('id', '')
cls = res.get('class', {})
avm = cls.get('action_value_mappings', {})
module = cls.get('module', '')
if avm:
devices.append((rid, len(avm), module))
return devices
def flatten_schema_to_goal(action_data):
"""将 schema 中嵌套的 goal 内容提升为顶层 schema去掉 feedback/result 包装"""
schema = action_data.get('schema', {})
goal_schema = schema.get('properties', {}).get('goal', {})
if goal_schema:
action_data = dict(action_data)
action_data['schema'] = goal_schema
return action_data
def extract_actions(data, device_id, output_dir):
"""提取指定设备的 action schema 到独立 JSON 文件"""
resources = data.get('resources', [])
for res in resources:
if res.get('id') == device_id:
cls = res.get('class', {})
module = cls.get('module', '')
avm = cls.get('action_value_mappings', {})
if not avm:
print(f"设备 {device_id} 没有 action_value_mappings")
return []
if module:
py_path = module.split(":")[0].replace(".", "/") + ".py"
class_name = module.split(":")[-1] if ":" in module else ""
print(f"Python 源码: {py_path}")
if class_name:
print(f"设备类: {class_name}")
os.makedirs(output_dir, exist_ok=True)
written = []
for action_name in sorted(avm.keys()):
action_data = flatten_schema_to_goal(avm[action_name])
filename = action_name.replace('-', '_') + '.json'
filepath = os.path.join(output_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(action_data, f, indent=2, ensure_ascii=False)
written.append(filename)
print(f" {filepath}")
return written
print(f"设备 {device_id} 未找到")
return []
def main():
args = sys.argv[1:]
explicit_registry = None
if "--registry" in args:
idx = args.index("--registry")
if idx + 1 < len(args):
explicit_registry = args[idx + 1]
args = args[:idx] + args[idx + 2:]
else:
print("错误: --registry 需要指定路径")
sys.exit(1)
registry_path = find_registry(explicit_registry)
if not registry_path:
print(f"错误: 找不到 {REGISTRY_FILENAME}")
print()
print("解决方法:")
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
print(" 2. 用 --registry 指定文件路径:")
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
print()
print("搜索过的路径:")
for p in [
os.path.join("unilabos_data", REGISTRY_FILENAME),
REGISTRY_FILENAME,
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
]:
print(f" - {p}")
sys.exit(1)
print(f"注册表: {registry_path}")
mtime = os.path.getmtime(registry_path)
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
data = load_registry(registry_path)
if len(args) == 0:
devices = list_devices(data)
print(f"\n找到 {len(devices)} 个设备:")
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
print("-" * 120)
for did, count, module in sorted(devices, key=lambda x: x[0]):
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
print(f"{did:<50} {count:>7} {py_path}")
elif len(args) == 2:
device_id = args[0]
output_dir = args[1]
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
written = extract_actions(data, device_id, output_dir)
if written:
print(f"\n共写入 {len(written)} 个 action 文件")
else:
print("用法:")
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
从 ak/sk 生成 UniLab API Authorization header。
算法: base64(ak:sk) → "Authorization: Lab <token>"
用法:
python gen_auth.py <ak> <sk>
python gen_auth.py --config <config.py>
示例:
python gen_auth.py myak mysk
python gen_auth.py --config experiments/config.py
"""
import base64
import re
import sys
def gen_auth(ak: str, sk: str) -> str:
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
return token
def extract_from_config(config_path: str) -> tuple:
"""从 config.py 中提取 ak 和 sk"""
with open(config_path, "r", encoding="utf-8") as f:
content = f.read()
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
if not ak_match or not sk_match:
return None, None
return ak_match.group(1), sk_match.group(1)
def main():
args = sys.argv[1:]
if len(args) == 2 and args[0] == "--config":
ak, sk = extract_from_config(args[1])
if not ak or not sk:
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
sys.exit(1)
print(f"配置文件: {args[1]}")
elif len(args) == 2:
ak, sk = args
else:
print("用法:")
print(" python gen_auth.py <ak> <sk>")
print(" python gen_auth.py --config <config.py>")
sys.exit(1)
token = gen_auth(ak, sk)
print(f"ak: {ak}")
print(f"sk: {sk}")
print()
print(f"Authorization header:")
print(f" Authorization: Lab {token}")
print()
print(f"curl 用法:")
print(f' curl -H "Authorization: Lab {token}" ...')
print()
print(f"Shell 变量:")
print(f' AUTH="Authorization: Lab {token}"')
if __name__ == "__main__":
main()

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()

View File

@@ -49,7 +49,7 @@ jobs:
uv pip uninstall enum34 || echo enum34 not installed, skipping
uv pip install .
- name: Run check mode (complete_registry)
- name: Run check mode (AST registry validation)
run: |
call conda activate check-env
echo Running check mode...

4
.gitignore vendored
View File

@@ -4,6 +4,8 @@ temp/
output/
unilabos_data/
pyrightconfig.json
.cursorignore
device_package*/
## Python
# Byte-compiled / optimized / DLL files
@@ -250,3 +252,5 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
test_config.py
/.claude
/.cursor

87
AGENTS.md Normal file
View File

@@ -0,0 +1,87 @@
# AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Also follow the monorepo-level rules in `../AGENTS.md`.
## Build & Development
```bash
# Install in editable mode (requires mamba env with python 3.11)
pip install -e .
uv pip install -r unilabos/utils/requirements.txt
# Run with a device graph
unilab --graph <graph.json> --config <config.py> --backend ros
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
# Common CLI flags
unilab --app_bridges websocket fastapi # communication bridges
unilab --test_mode # simulate hardware, no real execution
unilab --check_mode # CI validation of registry imports
unilab --skip_env_check # skip auto-install of dependencies
unilab --visual rviz|web|disable # visualization mode
unilab --is_slave # run as slave node
# Workflow upload subcommand
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
# Tests
pytest tests/ # all tests
pytest tests/resources/test_resourcetreeset.py # single test file
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
```
## Architecture
### Startup Flow
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
### Core Layers
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict``ResourceDictInstance``ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
### Configuration System
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
- Config files are `.py` files with matching class names (see `config/example_config.py`)
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
- Device topology defined in graph files (JSON with node-link format, or GraphML)
### Key Data Flow
1. Graph file → `graphio.read_node_link_json()``(nx.Graph, ResourceTreeSet, resource_links)`
2. `ResourceTreeSet` + `Registry``initialize_device.initialize_device_from_dict()``ROS2DeviceNode` instances
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
### Test Data
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
## Code Conventions
- Code comments and log messages in simplified Chinese
- Python 3.11+, type hints expected
- Pydantic models for data validation (`resource_tracker.py`)
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
- CLI argument dashes auto-converted to underscores for consistency
## Licensing
- Framework code: GPL-3.0
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute

4
CLAUDE.md Normal file
View File

@@ -0,0 +1,4 @@
Please follow the rules defined in:
@AGENTS.md

View File

@@ -15,6 +15,9 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
**示例:**
```python
from unilabos.registry.decorators import device, topic_config
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
class MockGripper:
def __init__(self):
self._position: float = 0.0
@@ -23,19 +26,23 @@ class MockGripper:
self._status = "Idle"
@property
@topic_config() # 添加 @topic_config 才会定时广播
def position(self) -> float:
return self._position
@property
@topic_config()
def velocity(self) -> float:
return self._velocity
@property
@topic_config()
def torque(self) -> float:
return self._torque
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
@property
@topic_config(period=2.0) # 可自定义发布周期
def status(self) -> str:
return self._status
@@ -149,7 +156,7 @@ my_device: # 设备唯一标识符
系统会自动分析您的 Python 驱动类并生成:
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
- `status_types`:从 `@topic_config` 装饰的 `@property` 方法自动识别状态属性
- `action_value_mappings`:从类方法自动生成动作映射
- `init_param_schema`:从 `__init__` 方法分析初始化参数
- `schema`:前端显示用的属性类型定义
@@ -179,7 +186,9 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
```python
from typing import Dict, Any
from unilabos.registry.decorators import device, topic_config
@device(id="my_device", category=["general"], description="My Device")
class MyDevice:
"""设备类文档字符串
@@ -198,8 +207,9 @@ class MyDevice:
# 初始化硬件连接
@property
@topic_config() # 必须添加 @topic_config 才会广播
def status(self) -> str:
"""设备状态(会自动广播)"""
"""设备状态(通过 @topic_config 广播)"""
return self._status
def my_action(self, param: float) -> Dict[str, Any]:
@@ -217,34 +227,61 @@ class MyDevice:
## 状态属性 vs 动作方法
### 状态属性(@property
### 状态属性(@property + @topic_config
状态属性会被自动识别并定期广播:
状态属性需要同时使用 `@property``@topic_config` 装饰器才会被识别并定期广播:
```python
from unilabos.registry.decorators import topic_config
@property
@topic_config() # 必须添加,否则不会广播
def temperature(self) -> float:
"""当前温度"""
return self._read_temperature()
@property
@topic_config(period=2.0) # 可自定义发布周期(秒)
def status(self) -> str:
"""设备状态: idle, running, error"""
return self._status
@property
@topic_config(name="ready") # 可自定义发布名称
def is_ready(self) -> bool:
"""设备是否就绪"""
return self._status == "idle"
```
也可以使用普通方法(非 @property)配合 `@topic_config`
```python
@topic_config(period=10.0)
def get_sensor_data(self) -> Dict[str, float]:
"""获取传感器数据get_ 前缀会自动去除,发布名为 sensor_data"""
return {"temp": self._temp, "humidity": self._humidity}
```
**`@topic_config` 参数**:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `period` | float | 5.0 | 发布周期(秒) |
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
| `qos` | int | 10 | QoS 深度 |
| `name` | str | None | 自定义发布名称 |
**发布名称优先级**`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
**特点**:
- 使用`@property`装饰器
- 只读,不能有参数
- 自动添加到注册表的`status_types`
- 必须使用 `@topic_config` 装饰器
- 支持 `@property` 和普通方法
- 添加到注册表的 `status_types`
- 定期发布到 ROS2 topic
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
### 动作方法
动作方法是设备可以执行的操作:
@@ -497,6 +534,7 @@ class LiquidHandler:
self._status = "idle"
@property
@topic_config()
def status(self) -> str:
return self._status
@@ -886,7 +924,52 @@ class MyDevice:
## 最佳实践
### 1. 类型注解
### 1. 使用 `@device` 装饰器标识设备
```python
from unilabos.registry.decorators import device
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
class MyDevice:
...
```
- `id`:设备唯一标识符,用于注册表匹配
- `category`:分类列表,前端用于分组显示
- `description`:设备描述
- `icon`:图标文件名(可选)
### 2. 使用 `@topic_config` 声明需要广播的状态
```python
from unilabos.registry.decorators import topic_config
# ✓ @property + @topic_config → 会广播
@property
@topic_config(period=2.0)
def temperature(self) -> float:
return self._temp
# ✓ 普通方法 + @topic_config → 会广播get_ 前缀自动去除)
@topic_config(period=10.0)
def get_sensor_data(self) -> Dict[str, float]:
return {"temp": self._temp}
# ✓ 使用 name 参数自定义发布名称
@property
@topic_config(name="ready")
def is_ready(self) -> bool:
return self._status == "idle"
# ✗ 仅有 @property没有 @topic_config → 不会广播
@property
def internal_state(self) -> str:
return self._state
```
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
### 3. 类型注解
```python
from typing import Dict, Any, Optional, List
@@ -901,7 +984,7 @@ def method(
pass
```
### 2. 文档字符串
### 4. 文档字符串
```python
def method(self, param: float) -> Dict[str, Any]:
@@ -923,7 +1006,7 @@ def method(self, param: float) -> Dict[str, Any]:
pass
```
### 3. 配置验证
### 5. 配置验证
```python
def __init__(self, config: Dict[str, Any]):
@@ -937,7 +1020,7 @@ def __init__(self, config: Dict[str, Any]):
self.baudrate = config['baudrate']
```
### 4. 资源清理
### 6. 资源清理
```python
def __del__(self):
@@ -946,7 +1029,7 @@ def __del__(self):
self.connection.close()
```
### 5. 设计前端友好的返回值
### 7. 设计前端友好的返回值
**记住:返回值会直接显示在 Web 界面**

View File

@@ -422,18 +422,20 @@ placeholder_keys:
### status_types
系统会扫描你的 Python 类,从状态方法property 或 get\_方法自动生成这部分:
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property`方法自动生成这部分:
```yaml
status_types:
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
is_heating: bool # 从 get_is_heating() 或 @property is_heating
status: str # 从 get_status() 或 @property status
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
is_heating: bool
status: str
```
**注意事项**
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
- 没有 `@topic_config``@property` 不会生成 status_types也不会广播
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature``temperature`
- 类型会自动转成相应的类型(如 `str``float``bool`
- 如果类型是 `Any``None` 或未知的,默认使用 `String`
@@ -537,11 +539,13 @@ class AdvancedLiquidHandler:
self._temperature = 25.0
@property
@topic_config()
def status(self) -> str:
"""设备状态"""
return self._status
@property
@topic_config()
def temperature(self) -> float:
"""当前温度"""
return self._temperature
@@ -809,21 +813,23 @@ my_temperature_controller:
你的设备类需要符合以下要求:
```python
from unilabos.common.device_base import DeviceBase
from unilabos.registry.decorators import device, topic_config
class MyDevice(DeviceBase):
@device(id="my_device", category=["temperature"], description="My Device")
class MyDevice:
def __init__(self, config):
"""初始化,参数会自动分析到 init_param_schema.config"""
super().__init__(config)
self.port = config.get('port', '/dev/ttyUSB0')
# 状态方法(会自动生成到 status_types
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播
@property
@topic_config()
def status(self):
"""返回设备状态"""
return "idle"
@property
@topic_config()
def temperature(self):
"""返回当前温度"""
return 25.0
@@ -1039,7 +1045,34 @@ resource.type # "resource"
### 代码规范
1. **始终使用类型注解**
1. **使用 `@device` 装饰器标识设备类**
```python
from unilabos.registry.decorators import device
@device(id="my_device", category=["heating"], description="My Device")
class MyDevice:
...
```
2. **使用 `@topic_config` 声明广播属性**
```python
from unilabos.registry.decorators import topic_config
# ✓ 需要广播的状态属性
@property
@topic_config(period=2.0)
def temperature(self) -> float:
return self._temp
# ✗ 仅有 @property 不会广播
@property
def internal_counter(self) -> int:
return self._counter
```
3. **始终使用类型注解**
```python
# ✓ 好
@@ -1051,7 +1084,7 @@ def method(self, resource, device):
pass
```
2. **提供有意义的参数名**
4. **提供有意义的参数名**
```python
# ✓ 好 - 清晰的参数名
@@ -1063,7 +1096,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
pass
```
3. **使用 Optional 表示可选参数**
5. **使用 Optional 表示可选参数**
```python
from typing import Optional
@@ -1076,7 +1109,7 @@ def method(
pass
```
4. **添加详细的文档字符串**
6. **添加详细的文档字符串**
```python
def method(
@@ -1096,13 +1129,13 @@ def method(
pass
```
5. **方法命名规范**
7. **方法命名规范**
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
- 动作方法使用动词开头
- 保持命名清晰、一致
6. **完善的错误处理**
8. **完善的错误处理**
- 实现完善的错误处理
- 添加日志记录
- 提供有意义的错误信息

View File

@@ -221,10 +221,10 @@ Laboratory A Laboratory B
```bash
# 实验室A
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
unilab --ak your_ak --sk your_sk --upload_registry
# 实验室B
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
unilab --ak your_ak --sk your_sk --upload_registry
```
---

File diff suppressed because it is too large Load Diff

View File

@@ -439,6 +439,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
1. 访问 Web 界面,进入"仪器耗材"模块
2. 在"仪器设备"区域找到并添加上述设备
3. 在"物料耗材"区域找到并添加容器
4. 在workstation中配置protocol_type包含PumpTransferProtocol
![添加Protocol类型](image/add_protocol.png)
![物料列表](image/material.png)
@@ -449,8 +452,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**操作步骤:**
1. 将两个 `container` 拖拽到 `workstation`
2.`virtual_transfer_pump` 拖拽到 `workstation`
3. 在画布上连接它们(建立父子关系)
2.`virtual_multiway_valve` 拖拽到 `workstation`
3. `virtual_transfer_pump` 拖拽到 `workstation`
4. 在画布上连接它们(建立父子关系)
![设备连接](image/links.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 415 KiB

View File

@@ -22,7 +22,6 @@ options:
--is_slave Run the backend as slave node (without host privileges).
--slave_no_host Skip waiting for host service in slave mode
--upload_registry Upload registry information when starting unilab
--use_remote_resource Use remote resources when starting unilab
--config CONFIG Configuration file path, supports .py format Python config files
--port PORT Port for web service information page
--disable_browser Disable opening information page on startup
@@ -85,7 +84,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
支持两种方式:
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
- **远程资源**使用 `--use_remote_resource` 从云端获取
- **远程资源**不指定本地文件即可
### 7. 注册表构建
@@ -196,7 +195,7 @@ unilab --config path/to/your/config.py
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
# 使用远程资源启动
unilab --ak your_ak --sk your_sk --use_remote_resource
unilab --ak your_ak --sk your_sk
# 更新注册表
unilab --ak your_ak --sk your_sk --complete_registry

View File

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

View File

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

View File

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

539
test/devices/test_prcxi.py Normal file
View File

@@ -0,0 +1,539 @@
import pytest
import json
import os
import asyncio
import collections
from typing import List, Dict, Any
from pylabrobot.resources import Coordinate
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
from unilabos.devices.liquid_handling.prcxi.prcxi import (
PRCXI9300Deck,
PRCXI9300Container,
PRCXI9300Trash,
PRCXI9300Handler,
PRCXI9300Backend,
DefaultLayout,
Material,
WorkTablets,
MatrixInfo
)
@pytest.fixture
def prcxi_materials() -> Dict[str, Any]:
"""加载 PRCXI 物料数据"""
print("加载 PRCXI 物料数据...")
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
with open(material_path, "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def prcxi_9300_deck() -> PRCXI9300Deck:
"""创建 PRCXI 9300 工作台"""
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
@pytest.fixture
def prcxi_9320_deck() -> PRCXI9300Deck:
"""创建 PRCXI 9320 工作台"""
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
@pytest.fixture
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
"""创建 PRCXI 9300 处理器(模拟模式)"""
return PRCXI9300Handler(
deck=prcxi_9300_deck,
host="192.168.1.201",
port=9999,
timeout=10.0,
channel_num=8,
axis="Left",
setup=False,
debug=True,
simulator=True,
matrix_id="test-matrix-9300"
)
@pytest.fixture
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
"""创建 PRCXI 9320 处理器(模拟模式)"""
return PRCXI9300Handler(
deck=prcxi_9320_deck,
host="192.168.1.201",
port=9999,
timeout=10.0,
channel_num=1,
axis="Right",
setup=False,
debug=True,
simulator=True,
matrix_id="test-matrix-9320",
is_9320=True
)
@pytest.fixture
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
"""创建 300μL 枪头盒"""
tip_rack = PRCXI9300Container(
name="tip_rack_300ul",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
tip_rack.load_state({
"Material": {
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
"Code": "ZX-001-300",
"Name": "300μL Tip头"
}
})
return tip_rack
@pytest.fixture
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
"""创建 10μL 枪头盒"""
tip_rack = PRCXI9300Container(
name="tip_rack_10ul",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
tip_rack.load_state({
"Material": {
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
"Code": "ZX-001-10+",
"Name": "10μL加长 Tip头"
}
})
return tip_rack
@pytest.fixture
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
"""创建 96 孔板"""
plate = PRCXI9300Container(
name="well_plate_96",
size_x=50,
size_y=50,
size_z=10,
category="plate",
ordering=collections.OrderedDict()
)
plate.load_state({
"Material": {
"uuid": prcxi_materials["96深孔板"]["uuid"],
"Code": "ZX-019-2.2",
"Name": "96深孔板"
}
})
return plate
@pytest.fixture
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
"""创建深孔板"""
plate = PRCXI9300Container(
name="deep_well_plate",
size_x=50,
size_y=50,
size_z=10,
category="plate",
ordering=collections.OrderedDict()
)
plate.load_state({
"Material": {
"uuid": prcxi_materials["96深孔板"]["uuid"],
"Code": "ZX-019-2.2",
"Name": "96深孔板"
}
})
return plate
@pytest.fixture
def trash_container(prcxi_materials) -> PRCXI9300Trash:
"""创建垃圾桶"""
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
trash.load_state({
"Material": {
"uuid": prcxi_materials["废弃槽"]["uuid"]
}
})
return trash
@pytest.fixture
def default_layout_9300() -> DefaultLayout:
"""创建 PRCXI 9300 默认布局"""
return DefaultLayout("PRCXI9300")
@pytest.fixture
def default_layout_9320() -> DefaultLayout:
"""创建 PRCXI 9320 默认布局"""
return DefaultLayout("PRCXI9320")
class TestPRCXIDeckSetup:
"""测试 PRCXI 工作台设置功能"""
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
"""测试 PRCXI 9300 工作台创建"""
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
assert len(prcxi_9300_deck.sites) == 6
assert prcxi_9300_deck._size_x == 100
assert prcxi_9300_deck._size_y == 100
assert prcxi_9300_deck._size_z == 100
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
"""测试 PRCXI 9320 工作台创建"""
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
assert len(prcxi_9320_deck.sites) == 16
assert prcxi_9320_deck._size_x == 100
assert prcxi_9320_deck._size_y == 100
assert prcxi_9320_deck._size_z == 100
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
"""测试容器分配到工作台"""
# 分配枪头盒
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
assert tip_rack_300ul in prcxi_9300_deck.children
# 分配孔板
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
assert well_plate_96 in prcxi_9300_deck.children
# 分配垃圾桶
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
assert trash_container in prcxi_9300_deck.children
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
"""测试容器物料信息加载"""
# 测试枪头盒物料信息
tip_material = tip_rack_300ul._unilabos_state["Material"]
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
assert tip_material["Name"] == "300μL Tip头"
# 测试孔板物料信息
plate_material = well_plate_96._unilabos_state["Material"]
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
assert plate_material["Name"] == "96深孔板"
class TestPRCXISingleStepOperations:
"""测试 PRCXI 单步操作功能"""
@pytest.mark.asyncio
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
"""测试单通道拾取枪头"""
# 将枪头盒添加到工作台
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
# 初始化处理器
await prcxi_9320_handler.setup()
# 设置枪头盒
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
# 创建模拟的枪头位置
from pylabrobot.resources import TipSpot, Tip
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
# 直接测试后端方法
from pylabrobot.liquid_handling import Pickup
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Load"
@pytest.mark.asyncio
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
"""测试多通道拾取枪头"""
# 设置枪头盒
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
# 拾取8个枪头
tip_spots = tip_rack_300ul.children[:8]
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
# 验证步骤已添加到待办列表
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Load"
@pytest.mark.asyncio
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道吸取液体"""
# 设置液体
well = well_plate_96.get_item("A1")
prcxi_9320_handler.set_liquid([well], ["water"], [50])
# 吸取液体
await prcxi_9320_handler.aspirate([well], [50], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Imbibing"
assert step["DosageNum"] == 50
@pytest.mark.asyncio
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道分配液体"""
# 分配液体
well = well_plate_96.get_item("A1")
await prcxi_9320_handler.dispense([well], [25], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Tapping"
assert step["DosageNum"] == 25
@pytest.mark.asyncio
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道混合液体"""
# 混合液体
well = well_plate_96.get_item("A1")
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Blending"
assert step["BlendingTimes"] == 3
assert step["DosageNum"] == 50
@pytest.mark.asyncio
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
"""测试丢弃枪头到垃圾桶"""
# 丢弃枪头
await prcxi_9320_handler.drop_tips([trash_container], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "UnLoad"
@pytest.mark.asyncio
async def test_discard_tips(self, prcxi_9320_handler):
"""测试丢弃枪头"""
# 丢弃枪头
await prcxi_9320_handler.discard_tips([0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "UnLoad"
@pytest.mark.asyncio
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
"""测试完整的液体转移工作流程"""
# 设置枪头盒和液体
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
source_well = well_plate_96.get_item("A1")
target_well = well_plate_96.get_item("B1")
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
# 创建协议
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
# 执行转移流程
tip_spot = tip_rack_10ul.get_item("A1")
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
await prcxi_9320_handler.aspirate([source_well], [50], [0])
await prcxi_9320_handler.dispense([target_well], [50], [0])
await prcxi_9320_handler.discard_tips([0])
# 验证所有步骤都已添加
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
class TestPRCXILayoutRecommendation:
"""测试 PRCXI 板位推荐功能"""
def test_9300_layout_creation(self, default_layout_9300):
"""测试 PRCXI 9300 布局创建"""
layout_info = default_layout_9300.get_layout()
assert layout_info["rows"] == 2
assert layout_info["columns"] == 3
assert len(layout_info["layout"]) == 6
assert layout_info["trash_slot"] == 6
assert "waste_liquid_slot" not in layout_info
def test_9320_layout_creation(self, default_layout_9320):
"""测试 PRCXI 9320 布局创建"""
layout_info = default_layout_9320.get_layout()
assert layout_info["rows"] == 4
assert layout_info["columns"] == 4
assert len(layout_info["layout"]) == 16
assert layout_info["trash_slot"] == 16
assert layout_info["waste_liquid_slot"] == 12
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
"""测试 PRCXI 9320 板位推荐功能"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 推荐布局
needs = [
("reagent_1", "96 细胞培养皿", 3),
("reagent_2", "12道储液槽", 1),
("reagent_3", "200μL Tip头", 7),
("reagent_4", "10μL加长 Tip头", 1),
]
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
# 验证返回结果
assert "MatrixId" in matrix_layout
assert "MatrixName" in matrix_layout
assert "MatrixCount" in matrix_layout
assert "WorkTablets" in matrix_layout
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
# 验证推荐的位置不包含预留位置
reserved_positions = {12, 16}
recommended_positions = [item["positions"] for item in layout_list]
for pos in recommended_positions:
assert pos not in reserved_positions
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
"""测试板位推荐空间不足的情况"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 尝试推荐超过可用空间的布局
needs = [
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置但只有14个可用
]
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
default_layout_9320.recommend_layout(needs)
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
"""测试板位推荐物料不存在的情况"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 尝试推荐不存在的物料
needs = [
("reagent_1", "不存在的物料", 1),
]
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
default_layout_9320.recommend_layout(needs)
class TestPRCXIBackendOperations:
"""测试 PRCXI 后端操作功能"""
def test_backend_initialization(self, prcxi_9300_handler):
"""测试后端初始化"""
backend = prcxi_9300_handler._unilabos_backend
assert isinstance(backend, PRCXI9300Backend)
assert backend._num_channels == 8
assert backend.debug is True
def test_protocol_creation(self, prcxi_9300_handler):
"""测试协议创建"""
backend = prcxi_9300_handler._unilabos_backend
backend.create_protocol("Test Protocol")
assert backend.protocol_name == "Test Protocol"
assert len(backend.steps_todo_list) == 0
def test_channel_validation(self):
"""测试通道验证"""
# 测试正确的8通道配置
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
result = PRCXI9300Backend.check_channels(valid_channels)
assert result == valid_channels
# 测试错误的通道配置
invalid_channels = [0, 1, 2, 3]
result = PRCXI9300Backend.check_channels(invalid_channels)
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
def test_matrix_info_creation(self, prcxi_9300_handler):
"""测试矩阵信息创建"""
backend = prcxi_9300_handler._unilabos_backend
backend.create_protocol("Test Protocol")
# 模拟运行协议时的矩阵信息创建
run_time = 1234567890
matrix_info = MatrixInfo(
MatrixId=f"{int(run_time)}",
MatrixName=f"protocol_{run_time}",
MatrixCount=len(backend.tablets_info),
WorkTablets=backend.tablets_info,
)
assert matrix_info["MatrixId"] == str(int(run_time))
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
assert "WorkTablets" in matrix_info
class TestPRCXIContainerOperations:
"""测试 PRCXI 容器操作功能"""
def test_container_serialization(self, tip_rack_300ul):
"""测试容器序列化"""
serialized = tip_rack_300ul.serialize_state()
assert "Material" in serialized
assert serialized["Material"]["Name"] == "300μL Tip头"
def test_container_deserialization(self, tip_rack_300ul):
"""测试容器反序列化"""
# 序列化
serialized = tip_rack_300ul.serialize_state()
# 创建新容器并反序列化
new_tip_rack = PRCXI9300Container(
name="new_tip_rack",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
new_tip_rack.load_state(serialized)
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
def test_trash_container_creation(self, prcxi_materials):
"""测试垃圾桶容器创建"""
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
trash.load_state({
"Material": {
"uuid": prcxi_materials["废弃槽"]["uuid"]
}
})
assert trash.name == "trash"
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
if __name__ == "__main__":
# 运行测试
pytest.main([__file__, "-v"])

View File

@@ -39,6 +39,11 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
self.current_tip = iter(make_tip_iter())
self.calls: List[Tuple[str, Any]] = []
def set_tiprack(self, tip_racks):
if not tip_racks:
return
super().set_tiprack(tip_racks)
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))

View File

@@ -39,6 +39,12 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
self.current_tip = iter(make_tip_iter())
self.calls: List[Tuple[str, Any]] = []
def set_tiprack(self, tip_racks):
# transfer_liquid 总会调用 set_tiprack测试用 Dummy 枪头时 tip_racks 为空,需保留自种子的 current_tip
if not tip_racks:
return
super().set_tiprack(tip_racks)
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
@@ -545,3 +551,58 @@ def test_mix_multiple_targets_supports_per_target_offsets():
assert aspirates[1]["flow_rates"] == [rates[1]]
def test_set_tiprack_per_type_resumes_first_physical_rack():
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
mk = lambda: Tip(
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
)
class TipTypeAlpha(TipRack):
pass
class TipTypeBeta(TipRack):
pass
def make_rack(cls: type, name: str) -> TipRack:
items = create_equally_spaced(
TipSpot,
num_items_x=12,
num_items_y=2,
dx=0,
dy=0,
dz=0,
item_dx=9,
item_dy=9,
size_x=1,
size_y=1,
make_tip=mk,
)
return cls(name, 120, 40, 10, items=items)
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
lh = LiquidHandlerAbstract(
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
)
flat1 = lh._flatten_tips_from_one(rack1)
assert len(flat1) == 24
lh.set_tiprack([rack1])
for i in range(12):
assert lh._get_next_tip() is flat1[i]
lh.set_tiprack([rack2])
spot_b = lh._get_next_tip()
assert "rack_phys_2" in spot_b.name
lh.set_tiprack([rack3])
spot_resume = lh._get_next_tip()
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]

213
tests/workflow/test.json Normal file
View File

@@ -0,0 +1,213 @@
{
"workflow": [
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines",
"targets": "Liquid_1",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines",
"targets": "Liquid_2",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines",
"targets": "Liquid_3",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_2",
"targets": "Liquid_4",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_2",
"targets": "Liquid_5",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_2",
"targets": "Liquid_6",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_3",
"targets": "dest_set",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_3",
"targets": "dest_set_2",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_3",
"targets": "dest_set_3",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
}
],
"reagent": {
"Liquid_1": {
"slot": 1,
"well": [
"A4",
"A7",
"A10"
],
"labware": "rep 1"
},
"Liquid_4": {
"slot": 1,
"well": [
"A4",
"A7",
"A10"
],
"labware": "rep 1"
},
"dest_set": {
"slot": 1,
"well": [
"A4",
"A7",
"A10"
],
"labware": "rep 1"
},
"Liquid_2": {
"slot": 2,
"well": [
"A3",
"A5",
"A8"
],
"labware": "rep 2"
},
"Liquid_5": {
"slot": 2,
"well": [
"A3",
"A5",
"A8"
],
"labware": "rep 2"
},
"dest_set_2": {
"slot": 2,
"well": [
"A3",
"A5",
"A8"
],
"labware": "rep 2"
},
"Liquid_3": {
"slot": 3,
"well": [
"A4",
"A6",
"A10"
],
"labware": "rep 3"
},
"Liquid_6": {
"slot": 3,
"well": [
"A4",
"A6",
"A10"
],
"labware": "rep 3"
},
"dest_set_3": {
"slot": 3,
"well": [
"A4",
"A6",
"A10"
],
"labware": "rep 3"
},
"cell_lines": {
"slot": 4,
"well": [
"A1",
"A3",
"A5"
],
"labware": "DRUG + YOYO-MEDIA"
},
"cell_lines_2": {
"slot": 4,
"well": [
"A1",
"A3",
"A5"
],
"labware": "DRUG + YOYO-MEDIA"
},
"cell_lines_3": {
"slot": 4,
"well": [
"A1",
"A3",
"A5"
],
"labware": "DRUG + YOYO-MEDIA"
}
}
}

View File

@@ -1 +1 @@
__version__ = "0.10.17"
__version__ = "0.10.19"

View File

@@ -1,8 +1,10 @@
import argparse
import asyncio
import os
import platform
import shutil
import signal
import subprocess
import sys
import threading
import time
@@ -24,6 +26,84 @@ from unilabos.config.config import load_config, BasicConfig, HTTPConfig
_restart_requested: bool = False
_restart_reason: str = ""
RESTART_EXIT_CODE = 42
def _build_child_argv():
"""Build sys.argv for child process, stripping supervisor-only arguments."""
result = []
skip_next = False
for arg in sys.argv:
if skip_next:
skip_next = False
continue
if arg in ("--restart_mode", "--restart-mode"):
continue
if arg in ("--auto_restart_count", "--auto-restart-count"):
skip_next = True
continue
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
continue
result.append(arg)
return result
def _run_as_supervisor(max_restarts: int):
"""
Supervisor process that spawns and monitors child processes.
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
it only launches the real process as a child and restarts it when the child
exits with RESTART_EXIT_CODE.
"""
child_argv = [sys.executable] + _build_child_argv()
restart_count = 0
print_status(
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
f"child command: {' '.join(child_argv)}",
"info",
)
while True:
print_status(
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
"info",
)
try:
process = subprocess.Popen(child_argv)
exit_code = process.wait()
except KeyboardInterrupt:
print_status("[Supervisor] Interrupted, terminating child process...", "info")
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
sys.exit(1)
if exit_code == RESTART_EXIT_CODE:
restart_count += 1
if restart_count > max_restarts:
print_status(
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
"warning",
)
sys.exit(1)
print_status(
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
"info",
)
time.sleep(2)
else:
if exit_code != 0:
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
else:
print_status("[Supervisor] Child exited normally", "info")
sys.exit(exit_code)
def load_config_from_file(config_path):
if config_path is None:
@@ -65,6 +145,13 @@ def parse_args():
action="append",
help="Path to the registry directory",
)
parser.add_argument(
"--devices",
type=str,
default=None,
action="append",
help="Path to Python code directory for AST-based device/resource scanning",
)
parser.add_argument(
"--working_dir",
type=str,
@@ -154,23 +241,53 @@ def parse_args():
action="store_true",
help="Skip environment dependency check on startup",
)
parser.add_argument(
"--complete_registry",
action="store_true",
default=False,
help="Complete registry information",
)
parser.add_argument(
"--check_mode",
action="store_true",
default=False,
help="Run in check mode for CI: validates registry imports and ensures no file changes",
)
parser.add_argument(
"--complete_registry",
action="store_true",
default=False,
help="Complete and rewrite YAML registry files using AST analysis results",
)
parser.add_argument(
"--no_update_feedback",
action="store_true",
help="Disable sending update feedback to server",
)
parser.add_argument(
"--test_mode",
action="store_true",
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",
default=False,
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
)
parser.add_argument(
"--restart_mode",
action="store_true",
default=False,
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
)
parser.add_argument(
"--auto_restart_count",
type=int,
default=500,
help="Maximum number of automatic restarts in restart mode (default: 500)",
)
# workflow upload subcommand
workflow_parser = subparsers.add_parser(
"workflow_upload",
@@ -204,6 +321,12 @@ def parse_args():
default=False,
help="Whether to publish the workflow (default: False)",
)
workflow_parser.add_argument(
"--description",
type=str,
default="",
help="Workflow description, used when publishing the workflow",
)
return parser
@@ -215,68 +338,88 @@ def main():
args = parser.parse_args()
args_dict = vars(args)
# Supervisor mode: spawn child processes and monitor for restart
if args_dict.get("restart_mode", False):
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
return
# 环境检查 - 检查并自动安装必需的包 (可选)
skip_env_check = args_dict.get("skip_env_check", False)
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")
# 加载配置文件优先加载config然后从env读取
config_path = args_dict.get("config")
if check_mode:
args_dict["working_dir"] = os.path.abspath(os.getcwd())
# 当 skip_env_check 时,默认使用当前目录作为 working_dir
if skip_env_check and not args_dict.get("working_dir") and not config_path:
# === 解析 working_dir ===
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
raw_working_dir = args_dict.get("working_dir")
if raw_working_dir:
working_dir = os.path.abspath(raw_working_dir)
elif config_path and os.path.exists(config_path):
working_dir = os.path.dirname(os.path.abspath(config_path))
else:
working_dir = os.path.abspath(os.getcwd())
print_status(f"跳过环境检查模式:使用当前目录作为工作目录 {working_dir}", "info")
# 检查当前目录是否有 local_config.py
local_config_in_cwd = os.path.join(working_dir, "local_config.py")
if os.path.exists(local_config_in_cwd):
config_path = local_config_in_cwd
# unilabos_data 子目录自动检测
if os.path.basename(working_dir) != "unilabos_data":
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
if os.path.isdir(unilabos_data_sub):
working_dir = unilabos_data_sub
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
# 未显式指定路径,默认使用 cwd/unilabos_data
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
# === 解析 config_path ===
if config_path and not os.path.exists(config_path):
# config_path 传入但不存在,尝试在 working_dir 中查找
candidate = os.path.join(working_dir, "local_config.py")
if os.path.exists(candidate):
config_path = candidate
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
else:
print_status(
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py"
f"请通过 --config 传入 local_config.py 文件路径",
"error",
)
os._exit(1)
elif not config_path:
# 规则3: 未传入 config_path尝试 working_dir/local_config.py
candidate = os.path.join(working_dir, "local_config.py")
if os.path.exists(candidate):
config_path = candidate
print_status(f"发现本地配置文件: {config_path}", "info")
else:
print_status(f"未指定config路径可通过 --config 传入 local_config.py 文件路径", "info")
elif os.getcwd().endswith("unilabos_data"):
working_dir = os.path.abspath(os.getcwd())
else:
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
if args_dict.get("working_dir"):
working_dir = args_dict.get("working_dir", "")
if config_path and not os.path.exists(config_path):
config_path = os.path.join(working_dir, "local_config.py")
if not os.path.exists(config_path):
print_status(
f"当前工作目录 {working_dir} 未找到local_config.py请通过 --config 传入 local_config.py 文件路径",
"error",
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
if check_mode or input() != "n":
os.makedirs(working_dir, exist_ok=True)
config_path = os.path.join(working_dir, "local_config.py")
shutil.copy(
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
config_path,
)
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
else:
os._exit(1)
elif config_path and os.path.exists(config_path):
working_dir = os.path.dirname(config_path)
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
config_path = os.path.join(working_dir, "local_config.py")
elif not skip_env_check and not config_path and (
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
):
print_status(f"未指定config路径可通过 --config 传入 local_config.py 文件路径", "info")
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
if input() != "n":
os.makedirs(working_dir, exist_ok=True)
config_path = os.path.join(working_dir, "local_config.py")
shutil.copy(
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
)
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
else:
os._exit(1)
# 加载配置文件 (check_mode 跳过)
print_status(f"当前工作目录为 {working_dir}", "info")
@@ -288,7 +431,9 @@ def main():
if hasattr(BasicConfig, "log_level"):
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
if file_path is not None:
logger.info(f"[LOG_FILE] {file_path}")
if args.addr != parser.get_default("addr"):
if args.addr == "test":
@@ -332,46 +477,66 @@ def main():
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
BasicConfig.test_mode = args_dict.get("test_mode", False)
if BasicConfig.test_mode:
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
BasicConfig.extra_resource = args_dict.get("extra_resource", False)
if BasicConfig.extra_resource:
print_status("启用额外资源加载将加载lab_开头的labware资源定义", "info")
BasicConfig.communication_protocol = "websocket"
machine_name = os.popen("hostname").read().strip()
machine_name = platform.node()
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
BasicConfig.machine_name = machine_name
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)
# 注册表 - check_mode 时强制启用 complete_registry
# Step 0: AST 分析优先 + YAML 注册表加载
# check_mode 和 upload_registry 都会执行实际 import 验证
devices_dirs = args_dict.get("devices", None)
complete_registry = args_dict.get("complete_registry", False) or check_mode
lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry)
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: complete_registry 完成后直接退出git diff 检测由 CI workflow 执行
# Check mode: 注册表验证完成后直接退出
if check_mode:
print_status("Check mode: complete_registry 完成,退出", "info")
device_count = len(lab_registry.device_type_registry)
resource_count = len(lab_registry.resource_type_registry)
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:
# 设备注册到服务端 - 需要 ak 和 sk
if BasicConfig.ak and BasicConfig.sk:
print_status("开始注册设备到服务端...", "info")
# print_status("开始注册设备到服务端...", "info")
try:
register_devices_and_resources(lab_registry)
print_status("设备注册完成", "info")
# print_status("设备注册完成", "info")
except Exception as e:
print_status(f"设备注册失败: {e}", "error")
else:
@@ -456,12 +621,16 @@ def main():
continue
# 如果从远端获取了物料信息,则与本地物料进行同步
if request_startup_json and "nodes" in request_startup_json:
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
@@ -553,6 +722,10 @@ def main():
open_browser=not args_dict["disable_browser"],
port=BasicConfig.port,
)
if restart_requested:
print_status("[Main] Restart requested, cleaning up...", "info")
cleanup_for_restart()
os._exit(RESTART_EXIT_CODE)
if __name__ == "__main__":

View File

@@ -54,6 +54,7 @@ class JobAddReq(BaseModel):
action_type: str = Field(
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
)
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")

View File

@@ -1,9 +1,8 @@
import json
import time
from typing import Optional, Tuple, Dict, Any
from typing import Any, Dict, Optional, Tuple
from unilabos.utils.log import logger
from unilabos.utils.type_check import TypeEncoder
from unilabos.utils.tools import normalize_json as _normalize_device
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
@@ -11,50 +10,63 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
注册设备和资源到服务器仅支持HTTP
"""
# 注册资源信息 - 使用HTTP方式
from unilabos.app.web.client import http_client
logger.info("[UniLab Register] 开始注册设备和资源...")
# 注册设备信息
devices_to_register = {}
for device_info in lab_registry.obtain_registry_device_info():
devices_to_register[device_info["id"]] = json.loads(
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
)
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
devices_to_register[device_info["id"]] = _normalize_device(device_info)
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
resources_to_register = {}
for resource_info in lab_registry.obtain_registry_resource_info():
resources_to_register[resource_info["id"]] = resource_info
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
if gather_only:
return devices_to_register, resources_to_register
# 注册设备
if devices_to_register:
try:
start_time = time.time()
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
response = http_client.resource_registry(
{"resources": list(devices_to_register.values())},
tag="device_registry",
)
cost_time = time.time() - start_time
if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
res_data = response.json() if response.status_code == 200 else {}
skipped = res_data.get("data", {}).get("skipped", False)
if skipped:
logger.info(
f"[UniLab Register] 设备注册跳过(内容未变化)"
f" {len(devices_to_register)}{cost_time:.3f}s"
)
elif response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
else:
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
except Exception as e:
logger.error(f"[UniLab Register] 设备注册异常: {e}")
# 注册资源
if resources_to_register:
try:
start_time = time.time()
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
response = http_client.resource_registry(
{"resources": list(resources_to_register.values())},
tag="resource_registry",
)
cost_time = time.time() - start_time
if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
res_data = response.json() if response.status_code == 200 else {}
skipped = res_data.get("data", {}).get("skipped", False)
if skipped:
logger.info(
f"[UniLab Register] 资源注册跳过(内容未变化)"
f" {len(resources_to_register)}{cost_time:.3f}s"
)
elif response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
else:
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
except Exception as e:
logger.error(f"[UniLab Register] 资源注册异常: {e}")
logger.info("[UniLab Register] 设备和资源注册完成.")

View File

@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
"result": {},
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
"goal_default": {i["name"]: i["default"] for i in v["args"]},
"handles": [],
"handles": {},
}
# 不生成已配置action的动作
for k, v in enhanced_info["action_methods"].items()
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
# 启动广播任务
@app.on_event("startup")
async def startup_event():
asyncio.create_task(broadcast_device_status())
asyncio.create_task(broadcast_status_page_data())
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")

View File

@@ -3,11 +3,13 @@ HTTP客户端模块
提供与远程服务器通信的客户端功能只有host需要用
"""
import gzip
import json
import os
from typing import List, Dict, Any, Optional
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
import requests
from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.utils.log import info
@@ -78,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,
)
@@ -109,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]
@@ -280,22 +284,54 @@ class HTTPClient:
)
return response
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
def resource_registry(
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
) -> requests.Response:
"""
注册资源到服务器
注册资源到服务器,同步保存请求/响应到 unilabos_data
Args:
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
Returns:
Response: API响应对象
"""
# 序列化一次,同时用于保存和发送
json_bytes = _fast_dumps(registry_data)
# 保存请求数据到 unilabos_data
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
try:
os.makedirs(BasicConfig.working_dir, exist_ok=True)
with open(req_path, "wb") as f:
f.write(_fast_dumps_pretty(registry_data))
logger.trace(f"注册表请求数据已保存: {req_path}")
except Exception as e:
logger.warning(f"保存注册表请求数据失败: {e}")
compressed_body = gzip.compress(json_bytes)
headers = {
"Authorization": f"Lab {self.auth}",
"Content-Type": "application/json",
"Content-Encoding": "gzip",
}
response = requests.post(
f"{self.remote_addr}/lab/resource",
json=registry_data,
headers={"Authorization": f"Lab {self.auth}"},
data=compressed_body,
headers=headers,
timeout=30,
)
# 保存响应数据到 unilabos_data
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
try:
with open(res_path, "w", encoding="utf-8") as f:
f.write(f"{response.status_code}\n{response.text}")
logger.trace(f"注册表响应数据已保存: {res_path}")
except Exception as e:
logger.warning(f"保存注册表响应数据失败: {e}")
if response.status_code not in [200, 201]:
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
if response.status_code == 200:
@@ -343,9 +379,10 @@ class HTTPClient:
edges: List[Dict[str, Any]],
tags: Optional[List[str]] = None,
published: bool = False,
description: str = "",
) -> Dict[str, Any]:
"""
导入工作流到服务器
导入工作流到服务器,如果 published 为 True则额外发起发布请求
Args:
name: 工作流名称(顶层)
@@ -355,13 +392,12 @@ class HTTPClient:
edges: 工作流边列表
tags: 工作流标签列表,默认为空列表
published: 是否发布工作流默认为False
description: 工作流描述,发布时使用
Returns:
Dict: API响应数据包含 code 和 data (uuid, name)
"""
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
payload = {
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
"name": name,
"data": {
"workflow_uuid": workflow_uuid,
@@ -369,7 +405,6 @@ class HTTPClient:
"nodes": nodes,
"edges": edges,
"tags": tags if tags is not None else [],
"published": published,
},
}
# 保存请求到文件
@@ -390,11 +425,51 @@ class HTTPClient:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"导入工作流失败: {response.text}")
return res
# 导入成功后,如果需要发布则额外发起发布请求
if published:
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
publish_res = self.workflow_publish(imported_uuid, description)
res["publish_result"] = publish_res
return res
else:
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
return {"code": response.status_code, "message": response.text}
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
"""
发布工作流
Args:
workflow_uuid: 工作流UUID
description: 工作流描述
Returns:
Dict: API响应数据
"""
payload = {
"uuid": workflow_uuid,
"description": description,
"published": True,
}
logger.info(f"正在发布工作流: {workflow_uuid}")
response = requests.patch(
f"{self.remote_addr}/lab/workflow/owner",
json=payload,
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"发布工作流失败: {response.text}")
else:
logger.info(f"工作流发布成功: {workflow_uuid}")
return res
else:
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
return {"code": response.status_code, "message": response.text}
# 创建默认客户端实例
http_client = HTTPClient()

View File

@@ -327,6 +327,7 @@ def job_add(req: JobAddReq) -> JobData:
queue_item,
action_type=action_type,
action_kwargs=action_args,
sample_material=req.sample_material,
server_info=server_info,
)

View File

@@ -86,7 +86,7 @@ def setup_server() -> FastAPI:
# 设置页面路由
try:
setup_web_pages(pages)
info("[Web] 已加载Web UI模块")
# info("[Web] 已加载Web UI模块")
except ImportError as e:
info(f"[Web] 未找到Web页面模块: {str(e)}")
except Exception as e:
@@ -138,7 +138,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
server_thread.start()
info("[Web] Server started, monitoring for restart requests...")
# info("[Web] Server started, monitoring for restart requests...")
# 监控重启标志
import unilabos.app.main as main_module

View File

@@ -23,9 +23,10 @@ from typing import Optional, Dict, Any, List
from urllib.parse import urlparse
from enum import Enum
from jedi.inference.gradual.typing import TypedDict
from typing_extensions import TypedDict
from unilabos.app.model import JobAddReq
from unilabos.resources.resource_tracker import ResourceDictType
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils.type_check import serialize_result_info
from unilabos.app.communication import BaseCommunicationClient
@@ -76,6 +77,7 @@ class JobInfo:
start_time: float
last_update_time: float = field(default_factory=time.time)
ready_timeout: Optional[float] = None # READY状态的超时时间
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
def update_timestamp(self):
"""更新最后更新时间"""
@@ -127,6 +129,15 @@ class DeviceActionManager:
# 总是将job添加到all_jobs中
self.all_jobs[job_info.job_id] = job_info
# always_free的动作不受排队限制直接设为READY
if job_info.always_free:
job_info.status = JobStatus.READY
job_info.update_timestamp()
job_info.set_ready_timeout(10)
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
return True
# 检查是否有正在执行或准备执行的任务
if device_key in self.active_jobs:
# 有正在执行或准备执行的任务,加入队列
@@ -154,7 +165,7 @@ class DeviceActionManager:
job_info.set_ready_timeout(10) # 设置10秒超时
self.active_jobs[device_key] = job_info
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
return True
def start_job(self, job_id: str) -> bool:
@@ -176,11 +187,15 @@ class DeviceActionManager:
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
return False
# 检查设备上是否是这个job
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
return False
# always_free的job不需要检查active_jobs
if not job_info.always_free:
# 检查设备上是否是这个job
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
job_log = format_job_log(
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
)
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
return False
# 开始执行任务将状态从READY转换为STARTED
job_info.status = JobStatus.STARTED
@@ -203,6 +218,13 @@ class DeviceActionManager:
job_info = self.all_jobs[job_id]
device_key = job_info.device_action_key
# always_free的job直接清理不影响队列
if job_info.always_free:
job_info.status = JobStatus.ENDED
job_info.update_timestamp()
del self.all_jobs[job_id]
return None
# 移除活跃任务
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
del self.active_jobs[device_key]
@@ -210,8 +232,9 @@ class DeviceActionManager:
job_info.update_timestamp()
# 从all_jobs中移除已结束的job
del self.all_jobs[job_id]
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
# job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
# logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
pass
else:
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
@@ -227,15 +250,20 @@ class DeviceActionManager:
next_job_log = format_job_log(
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
)
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
return next_job
return None
def get_active_jobs(self) -> List[JobInfo]:
"""获取所有正在执行的任务"""
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
with self.lock:
return list(self.active_jobs.values())
jobs = list(self.active_jobs.values())
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
for job in self.all_jobs.values():
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
jobs.append(job)
return jobs
def get_queued_jobs(self) -> List[JobInfo]:
"""获取所有排队中的任务"""
@@ -260,6 +288,14 @@ class DeviceActionManager:
job_info = self.all_jobs[job_id]
device_key = job_info.device_action_key
# always_free的job直接清理
if job_info.always_free:
job_info.status = JobStatus.ENDED
del self.all_jobs[job_id]
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
return True
# 如果是正在执行的任务
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
# 清理active job状态
@@ -268,7 +304,7 @@ class DeviceActionManager:
# 从all_jobs中移除
del self.all_jobs[job_id]
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
# 启动下一个任务
if device_key in self.device_queues and self.device_queues[device_key]:
@@ -281,7 +317,7 @@ class DeviceActionManager:
next_job_log = format_job_log(
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
)
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
return True
# 如果是排队中的任务
@@ -295,7 +331,7 @@ class DeviceActionManager:
job_log = format_job_log(
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
)
logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
return True
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
@@ -333,13 +369,18 @@ class DeviceActionManager:
timeout_jobs = []
with self.lock:
# 统计READY状态的任务数量
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
ready_candidates = list(self.active_jobs.values())
for job in self.all_jobs.values():
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
ready_candidates.append(job)
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
if ready_jobs_count > 0:
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
# 找到所有超时的READY任务只检测不处理
for job_info in self.active_jobs.values():
for job_info in ready_candidates:
if job_info.is_ready_timeout():
timeout_jobs.append(job_info)
job_log = format_job_log(
@@ -368,6 +409,7 @@ class MessageProcessor:
# 线程控制
self.is_running = False
self.thread = None
self._loop = None # asyncio event loop引用用于外部关闭websocket
self.reconnect_count = 0
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
@@ -394,22 +436,31 @@ class MessageProcessor:
def stop(self) -> None:
"""停止消息处理线程"""
self.is_running = False
# 主动关闭websocket以快速中断消息接收循环
ws = self.websocket
loop = self._loop
if ws and loop and loop.is_running():
try:
asyncio.run_coroutine_threadsafe(ws.close(), loop)
except Exception:
pass
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2)
logger.info("[MessageProcessor] Stopped")
def _run(self):
"""运行消息处理主循环"""
loop = asyncio.new_event_loop()
self._loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(self._connection_handler())
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._connection_handler())
except Exception as e:
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
logger.error(traceback.format_exc())
finally:
if loop:
loop.close()
if self._loop:
self._loop.close()
self._loop = None
async def _connection_handler(self):
"""处理WebSocket连接和重连逻辑"""
@@ -426,8 +477,10 @@ class MessageProcessor:
async with websockets.connect(
self.websocket_url,
ssl=ssl_context,
open_timeout=20,
ping_interval=WSConfig.ping_interval,
ping_timeout=10,
close_timeout=5,
additional_headers={
"Authorization": f"Lab {BasicConfig.auth_secret()}",
"EdgeSession": f"{self.session_id}",
@@ -438,77 +491,94 @@ class MessageProcessor:
self.connected = True
self.reconnect_count = 0
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
# 启动发送协程
send_task = asyncio.create_task(self._send_handler())
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
# 每次连接(含重连)后重新向服务端注册,
# 否则服务端不知道客户端已上线,不会推送消息。
if self.websocket_client:
self.websocket_client.publish_host_ready()
try:
# 接收消息循环
await self._message_handler()
finally:
# 必须在 async with __aexit__ 之前停止 send_task
# 否则 send_task 会在关闭握手期间继续发送数据,
# 干扰 websockets 库的内部清理,导致 task 泄漏。
self.connected = False
send_task.cancel()
try:
await send_task
except asyncio.CancelledError:
pass
self.connected = False
except websockets.exceptions.ConnectionClosed:
logger.warning("[MessageProcessor] Connection closed")
self.connected = False
logger.warning("[MessageProcessor] 与服务端连接中断")
except TimeoutError:
logger.warning(
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
)
except websockets.exceptions.InvalidStatus as e:
logger.warning(
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
)
except Exception as e:
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
logger.error(traceback.format_exc())
self.connected = False
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
finally:
self.connected = False
self.websocket = None
# 重连逻辑
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
if not self.is_running:
break
if self.reconnect_count < WSConfig.max_reconnect_attempts:
self.reconnect_count += 1
backoff = WSConfig.reconnect_interval
logger.info(
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
)
await asyncio.sleep(WSConfig.reconnect_interval)
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
await asyncio.sleep(backoff)
else:
logger.error("[MessageProcessor] Max reconnection attempts reached")
break
else:
self.reconnect_count -= 1
async def _message_handler(self):
"""处理接收到的消息"""
"""处理接收到的消息
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
正确清理内部 task避免 task 泄漏。
"""
if not self.websocket:
logger.error("[MessageProcessor] WebSocket connection is None")
return
try:
async for message in self.websocket:
try:
data = json.loads(message)
message_type = data.get("action", "")
message_data = data.get("data")
if self.session_id and self.session_id == data.get("edge_session"):
await self._process_message(message_type, message_data)
async for message in self.websocket:
try:
data = json.loads(message)
message_type = data.get("action", "")
message_data = data.get("data")
if self.session_id and self.session_id == data.get("edge_session"):
await self._process_message(message_type, message_data)
else:
if message_type.endswith("_material"):
logger.trace(
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
)
logger.debug(
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
)
else:
if message_type.endswith("_material"):
logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}")
logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}")
else:
await self._process_message(message_type, message_data)
except json.JSONDecodeError:
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
except Exception as e:
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
logger.error(traceback.format_exc())
except websockets.exceptions.ConnectionClosed:
logger.info("[MessageProcessor] Message handler stopped - connection closed")
except Exception as e:
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
logger.error(traceback.format_exc())
await self._process_message(message_type, message_data)
except json.JSONDecodeError:
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
except Exception as e:
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
logger.error(traceback.format_exc())
async def _send_handler(self):
"""处理发送队列中的消息"""
@@ -540,7 +610,7 @@ class MessageProcessor:
try:
message_str = json.dumps(msg, ensure_ascii=False)
await self.websocket.send(message_str)
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
# logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
except Exception as e:
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
logger.error(traceback.format_exc())
@@ -557,6 +627,7 @@ class MessageProcessor:
except asyncio.CancelledError:
logger.debug("[MessageProcessor] Send handler cancelled")
raise
except Exception as e:
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
logger.error(traceback.format_exc())
@@ -565,7 +636,7 @@ class MessageProcessor:
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
"""处理收到的消息"""
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
logger.trace(f"[MessageProcessor] Processing message: {message_type}")
try:
if message_type == "pong":
@@ -588,6 +659,10 @@ class MessageProcessor:
# elif message_type == "session_id":
# self.session_id = message_data.get("session_id")
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
elif message_type == "add_device":
await self._handle_device_manage(message_data, "add")
elif message_type == "remove_device":
await self._handle_device_manage(message_data, "remove")
elif message_type == "request_restart":
await self._handle_request_restart(message_data)
else:
@@ -603,6 +678,24 @@ class MessageProcessor:
if host_node:
host_node.handle_pong_response(pong_data)
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
"""检查该action是否标记为always_free通过HostNode统一的_action_value_mappings查找"""
try:
host_node = HostNode.get_instance(0)
if not host_node:
return False
# noinspection PyProtectedMember
action_mappings = host_node._action_value_mappings.get(device_id)
if not action_mappings:
return False
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
return action_mappings[key].get("always_free", False)
return False
except Exception:
return False
async def _handle_query_action_state(self, data: Dict[str, Any]):
"""处理query_action_state消息"""
device_id = data.get("device_id", "")
@@ -617,6 +710,9 @@ class MessageProcessor:
device_action_key = f"/devices/{device_id}/{action_name}"
# 检查action是否为always_free
action_always_free = self._check_action_always_free(device_id, action_name)
# 创建任务信息
job_info = JobInfo(
job_id=job_id,
@@ -626,6 +722,7 @@ class MessageProcessor:
device_action_key=device_action_key,
status=JobStatus.QUEUE,
start_time=time.time(),
always_free=action_always_free,
)
# 添加到设备管理器
@@ -637,13 +734,13 @@ class MessageProcessor:
await self._send_action_state_response(
device_id, action_name, task_id, job_id, "query_action_status", True, 0
)
logger.info(f"[MessageProcessor] Job {job_log} can start immediately")
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
else:
# 需要排队
await self._send_action_state_response(
device_id, action_name, task_id, job_id, "query_action_status", False, 10
)
logger.info(f"[MessageProcessor] Job {job_log} queued")
logger.trace(f"[MessageProcessor] Job {job_log} queued")
# 通知QueueProcessor有新的队列更新
if self.queue_processor:
@@ -652,9 +749,37 @@ class MessageProcessor:
async def _handle_job_start(self, data: Dict[str, Any]):
"""处理job_start消息"""
try:
if not data.get("sample_material"):
data["sample_material"] = {}
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}")
@@ -683,6 +808,7 @@ class MessageProcessor:
queue_item,
action_type=req.action_type,
action_kwargs=req.action_args,
sample_material=req.sample_material,
server_info=req.server_info,
)
@@ -847,9 +973,7 @@ class MessageProcessor:
device_action_groups[key_add] = []
device_action_groups[key_add].append(item["uuid"])
logger.info(
f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}"
)
logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}")
else:
# 正常update
key = (device_id, "update")
@@ -863,7 +987,9 @@ class MessageProcessor:
device_action_groups[key] = []
device_action_groups[key].append(item["uuid"])
logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
logger.trace(
f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}"
)
# 为每个(device_id, action)创建独立的更新线程
for (device_id, actual_action), items in device_action_groups.items():
@@ -899,45 +1025,77 @@ class MessageProcessor:
)
thread.start()
async def _handle_device_manage(self, device_list: list[ResourceDictType], action: str):
"""Handle add_device / remove_device from LabGo server."""
if not device_list:
return
for item in device_list:
target_node_id = item.get("target_node_id", "host_node")
def _notify(target_id: str, act: str, cfg: ResourceDictType):
try:
host_node = HostNode.get_instance(timeout=5)
if not host_node:
logger.error(f"[DeviceManage] HostNode not available for {act}_device")
return
success = host_node.notify_device_manage(target_id, act, cfg)
if success:
logger.info(f"[DeviceManage] {act}_device completed on {target_id}")
else:
logger.warning(f"[DeviceManage] {act}_device failed on {target_id}")
except Exception as e:
logger.error(f"[DeviceManage] Error in {act}_device: {e}")
logger.error(traceback.format_exc())
thread = threading.Thread(
target=_notify,
args=(target_node_id, action, item),
daemon=True,
name=f"DeviceManage-{action}-{item.get('id', '')}",
)
thread.start()
async def _handle_request_restart(self, data: Dict[str, Any]):
"""
处理重启请求
当LabGo发送request_restart时执行清理并触发重启
"""
reason = data.get("reason", "unknown")
delay = data.get("delay", 2) # 默认延迟2秒
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
# 发送确认消息
if self.websocket_client:
await self.websocket_client.send_message({
"action": "restart_acknowledged",
"data": {"reason": reason, "delay": delay}
})
self.send_message(
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
)
# 设置全局重启标志
import unilabos.app.main as main_module
main_module._restart_requested = True
main_module._restart_reason = reason
# 延迟后执行清理
await asyncio.sleep(delay)
# 在新线程中执行清理,避免阻塞当前事件循环
def do_cleanup():
import time
time.sleep(0.5) # 给当前消息处理完成的时间
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
try:
from unilabos.app.utils import cleanup_for_restart
if cleanup_for_restart():
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
else:
logger.error("[MessageProcessor] Cleanup failed")
except Exception as e:
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
cleanup_thread.start()
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
@@ -955,7 +1113,7 @@ class MessageProcessor:
"task_id": task_id,
"job_id": job_id,
"free": free,
"need_more": need_more,
"need_more": need_more + 1,
},
}
@@ -1013,6 +1171,7 @@ class QueueProcessor:
def stop(self) -> None:
"""停止队列处理线程"""
self.is_running = False
self.queue_update_event.set() # 立即唤醒等待中的线程
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2)
logger.info("[QueueProcessor] Stopped")
@@ -1094,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)
@@ -1113,6 +1272,11 @@ class QueueProcessor:
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
for job_info in queued_jobs:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY
# 此时不应再发送 busy/need_more否则会覆盖已发出的 free=True 通知
if job_info.status != JobStatus.QUEUE:
continue
message = {
"action": "report_action_state",
"data": {
@@ -1122,13 +1286,13 @@ 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)
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
if success:
logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
else:
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
@@ -1151,7 +1315,7 @@ class QueueProcessor:
job_info.action_name,
)
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}")
# 结束任务,获取下一个可执行的任务
next_job = self.device_manager.end_job(job_id)
@@ -1171,8 +1335,8 @@ class QueueProcessor:
},
}
self.message_processor.send_message(message)
next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start")
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
# 立即触发下一轮状态检查
self.notify_queue_update()
@@ -1205,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)
@@ -1261,8 +1429,8 @@ class WebSocketClient(BaseCommunicationClient):
message = {"action": "normal_exit", "data": {"session_id": session_id}}
self.message_processor.send_message(message)
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
# 给一点时间让消息发送出去
time.sleep(1)
# send_handler 每100ms检查一次队列等300ms足以让消息发
time.sleep(0.3)
except Exception as e:
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
@@ -1294,7 +1462,7 @@ class WebSocketClient(BaseCommunicationClient):
},
}
self.message_processor.send_message(message)
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
def publish_job_status(
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
@@ -1304,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.info(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": {
@@ -1335,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:
@@ -1375,15 +1552,17 @@ class WebSocketClient(BaseCommunicationClient):
# 收集设备信息
devices = []
machine_name = BasicConfig.machine_name
try:
host_node = HostNode.get_instance(0)
if host_node:
# 获取设备信息
for device_id, namespace in host_node.devices_names.items():
device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
device_key = (
f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
)
is_online = device_key in host_node._online_devices
# 获取设备的动作信息
actions = {}
for action_id, client in host_node._action_clients.items():
@@ -1394,16 +1573,18 @@ class WebSocketClient(BaseCommunicationClient):
"action_path": action_id,
"action_type": str(type(client).__name__),
}
devices.append({
"device_id": device_id,
"namespace": namespace,
"device_key": device_key,
"is_online": is_online,
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
"actions": actions,
})
devices.append(
{
"device_id": device_id,
"namespace": namespace,
"device_key": device_key,
"is_online": is_online,
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
"actions": actions,
}
)
logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready")
except Exception as e:
logger.warning(f"[WebSocketClient] Error collecting device info: {e}")

View File

@@ -95,8 +95,29 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
return total_volume
def is_integrated_pump(node_name):
return "pump" in node_name and "valve" in node_name
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
"""
判断是否为泵阀一体设备
"""
class_lower = (node_class or "").lower()
name_lower = (node_name or "").lower()
if "pump" not in class_lower and "pump" not in name_lower:
return False
integrated_markers = [
"valve",
"pump_valve",
"pumpvalve",
"integrated",
"transfer_pump",
]
for marker in integrated_markers:
if marker in class_lower or marker in name_lower:
return True
return False
def find_connected_pump(G, valve_node):
@@ -186,7 +207,9 @@ def build_pump_valve_maps(G, pump_backbone):
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
for node in filtered_backbone:
if is_integrated_pump(G.nodes[node]["class"]):
node_data = G.nodes.get(node, {})
node_class = node_data.get("class", "") or ""
if is_integrated_pump(node_class, node):
pumps_from_node[node] = node
valve_from_node[node] = node
debug_print(f" - 集成泵-阀: {node}")

View File

@@ -22,6 +22,9 @@ class BasicConfig:
startup_json_path = None # 填写绝对路径
disable_browser = False # 禁止浏览器自动打开
port = 8002 # 本地HTTP服务
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
extra_resource = False # 是否加载lab_开头的额外资源
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
@@ -38,7 +41,7 @@ class BasicConfig:
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
ping_interval = 20 # ping间隔
# HTTP配置
@@ -144,5 +147,5 @@ def load_config(config_path=None):
traceback.print_exc()
exit(1)
else:
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
load_config(config_path)

View File

@@ -1,4 +1,3 @@
from abc import abstractmethod
from functools import wraps
import inspect

View File

@@ -201,17 +201,42 @@ class ResourceVisualization:
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
@staticmethod
def _ensure_ros2_env() -> dict:
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
import sys
env = dict(os.environ)
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
env["AMENT_PREFIX_PATH"] = candidate
os.environ["AMENT_PREFIX_PATH"] = candidate
extra_bin_dirs = [
os.path.join(conda_prefix, "Library", "bin"),
os.path.join(conda_prefix, "Library", "lib"),
os.path.join(conda_prefix, "Scripts"),
conda_prefix,
]
current_path = env.get("PATH", "")
for d in extra_bin_dirs:
if d not in current_path:
current_path = d + os.pathsep + current_path
env["PATH"] = current_path
os.environ["PATH"] = current_path
return env
def create_launch_description(self) -> LaunchDescription:
"""
创建launch描述包含robot_state_publisher和move_group节点
Args:
urdf_str: URDF文本
Returns:
LaunchDescription: launch描述对象
"""
# 检查ROS 2环境变量
launch_env = self._ensure_ros2_env()
if "AMENT_PREFIX_PATH" not in os.environ:
raise OSError(
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
@@ -290,7 +315,7 @@ class ResourceVisualization:
{"robot_description": robot_description},
ros2_controllers,
],
env=dict(os.environ)
env=launch_env,
)
)
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
@@ -300,7 +325,7 @@ class ResourceVisualization:
executable="spawner",
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
output="screen",
env=dict(os.environ)
env=launch_env,
)
)
controllers.append(
@@ -309,7 +334,7 @@ class ResourceVisualization:
executable="spawner",
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
output="screen",
env=dict(os.environ)
env=launch_env,
)
)
for i in controllers:
@@ -317,7 +342,6 @@ class ResourceVisualization:
else:
ros2_controllers = None
# 创建robot_state_publisher节点
robot_state_publisher = nd(
package='robot_state_publisher',
executable='robot_state_publisher',
@@ -327,9 +351,8 @@ class ResourceVisualization:
'robot_description': robot_description,
'use_sim_time': False
},
# kinematics_dict
],
env=dict(os.environ)
env=launch_env,
)
@@ -361,7 +384,7 @@ class ResourceVisualization:
executable='move_group',
output='screen',
parameters=moveit_params,
env=dict(os.environ)
env=launch_env,
)
@@ -379,13 +402,11 @@ class ResourceVisualization:
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
output='screen',
parameters=[
{'robot_description_kinematics': kinematics_dict,
},
{'robot_description_kinematics': kinematics_dict},
robot_description_planning,
planning_pipelines,
],
env=dict(os.environ)
env=launch_env,
)
self.launch_description.add_action(rviz_node)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
from typing import Any, Dict, Optional
from .prcxi import PRCXI9300ModuleSite
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
"""
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
设计目标:
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier
- 顶面存在一个站点site可吸附标准板类资源plate / tip_rack / tube_rack 等)。
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
module_type: Optional[str] = None,
category: str = "module",
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
material_info=material_info,
model=model,
category=category,
**kwargs,
)
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
self.module_type = module_type or "generic"
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
self._unilabos_state = {}
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
if material_info is not None and "Material" not in self._unilabos_state:
self._unilabos_state["Material"] = material_info
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
self._unilabos_state.setdefault("category", category)
self._unilabos_state["module_type"] = module_type
# ============================================================================
# 具体功能模块定义
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
# ============================================================================
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="heating",
model="PRCXI_Heating_Module",
material_info={
"uuid": "TODO-HEATING-MODULE-UUID",
"Code": "HEAT-MOD",
"Name": "PRCXI 加热模块",
"SupplyType": 3,
},
)
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
"""金属冷却模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="metal_cooling",
model="PRCXI_MetalCooling_Module",
material_info={
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
"Code": "METAL-COOL-MOD",
"Name": "PRCXI 金属冷却模块",
"SupplyType": 3,
},
)
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=50.0,
module_type="shaking",
model="PRCXI_Shaking_Module",
material_info={
"uuid": "TODO-SHAKING-MODULE-UUID",
"Code": "SHAKE-MOD",
"Name": "PRCXI 震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=55.0,
module_type="heating_shaking",
model="PRCXI_Heating_Shaking_Module",
material_info={
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
"Code": "HEAT-SHAKE-MOD",
"Name": "PRCXI 加热震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
"""磁吸模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
module_type="magnetic",
model="PRCXI_Magnetic_Module",
material_info={
"uuid": "TODO-MAGNETIC-MODULE-UUID",
"Code": "MAG-MOD",
"Name": "PRCXI 磁吸模块",
"SupplyType": 3,
},
)

View File

@@ -59,6 +59,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.total_height = total_height
self.joint_config = kwargs.get("joint_config", None)
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
self.simulate_rviz = kwargs.get("simulate_rviz", False)
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
@@ -69,7 +70,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.joint_state_publisher = LiquidHandlerJointPublisher(
joint_config=self.joint_config,
lh_device_id=self.lh_device_id,
simulate_rviz=True)
simulate_rviz=self.simulate_rviz)
# 启动ROS executor
self.executor = rclpy.executors.MultiThreadedExecutor()

View File

@@ -19,10 +19,11 @@ from rclpy.node import Node
import re
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", registry_name: str = "lh_joint_publisher", **kwargs):
super().__init__(
driver_instance=self,
device_id=device_id,
registry_name=registry_name,
status_types={},
action_value_mappings={},
hardware_interface={},

View File

@@ -42,6 +42,7 @@ class LiquidHandlerJointPublisher(Node):
while self.resource_action is None:
self.resource_action = self.check_tf_update_actions()
time.sleep(1)
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):

View File

@@ -2,6 +2,7 @@ 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
@@ -171,173 +172,160 @@ class MoveitInterface:
return True
def pick_and_place(self, command: str):
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:
"""
Using MoveIt to make the robotic arm pick or place materials to a target point.
使用 MoveIt 完成抓取/放置等序列pick/place/side_pick/side_place
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
必选option, move_group, status。
可选resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
无返回值;失败时提前 return 或打印异常。
"""
result = SendCmd.Result()
try:
cmd_str = str(command).replace("'", '"')
cmd_dict = json.loads(cmd_str)
if option not in self.move_option:
raise ValueError(f"Invalid option: {option}")
if cmd_dict["option"] in self.move_option:
option_index = self.move_option.index(cmd_dict["option"])
place_flag = option_index % 2
option_index = self.move_option.index(option)
place_flag = option_index % 2
config = {}
function_list = []
config: dict = {"move_group": move_group}
if speed is not None:
config["speed"] = speed
if retry is not None:
config["retry"] = retry
status = cmd_dict["status"]
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
function_list = []
joint_positions_ = self.joint_poses[move_group][status]
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
# 夹取 / 放置:绑定 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"))
# 夹取
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
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,
)
)
else:
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
constraints = []
if "constraints" in cmd_dict.keys():
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,
]
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,
)
)
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
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,
]
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)
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,
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
)
] + function_list
pose[2] += float(cmd_dict["lift_height"])
function_list.append(
lambda: self.moveit_task(
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
)
)
end_pose = pose
end_pose = list(deep_pose)
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"])
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
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
for i in range(len(function_list)):
if i == 0:
self.cartesian_flag = False
else:
function_list = [
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
] + function_list
self.cartesian_flag = True
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
re = function_list[i]()
if not re:
print(i, re)
raise ValueError(f"Failed to execute moveit task: {i}")
except Exception as e:
print(e)
self.cartesian_flag = False
result.success = False
return result
raise e
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

@@ -15,35 +15,35 @@ class VirtualPumpMode(Enum):
class VirtualTransferPump:
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
"""
初始化虚拟转移泵
Args:
device_id: 设备ID
config: 配置字典包含max_volume, port等参数
**kwargs: 其他参数,确保兼容性
"""
self.device_id = device_id or "virtual_transfer_pump"
# 从config或kwargs中获取参数确保类型正确
if config:
self.max_volume = float(config.get('max_volume', 25.0))
self.port = config.get('port', 'VIRTUAL')
self.max_volume = float(config.get("max_volume", 25.0))
self.port = config.get("port", "VIRTUAL")
else:
self.max_volume = float(kwargs.get('max_volume', 25.0))
self.port = kwargs.get('port', 'VIRTUAL')
self._transfer_rate = float(kwargs.get('transfer_rate', 0))
self.mode = kwargs.get('mode', VirtualPumpMode.Normal)
self.max_volume = float(kwargs.get("max_volume", 25.0))
self.port = kwargs.get("port", "VIRTUAL")
self._transfer_rate = float(kwargs.get("transfer_rate", 0))
self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
# 状态变量 - 确保都是正确类型
self._status = "Idle"
self._position = 0.0 # float
self._max_velocity = 5.0 # float
self._max_velocity = 5.0 # float
self._current_volume = 0.0 # float
# 🚀 新增:快速模式设置 - 大幅缩短执行时间
@@ -52,14 +52,16 @@ class VirtualTransferPump:
self._fast_dispense_time = 1.0 # 快速喷射时间(秒)
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s")
print(
f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s"
)
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化虚拟泵 🚀"""
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id}")
@@ -68,33 +70,33 @@ class VirtualTransferPump:
self._current_volume = 0.0
self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰")
return True
async def cleanup(self) -> bool:
"""清理虚拟泵 🧹"""
self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚")
self._status = "Idle"
self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤")
return True
# 基本属性
@property
def status(self) -> str:
return self._status
@property
def position(self) -> float:
"""当前柱塞位置 (ml) 📍"""
return self._position
@property
def current_volume(self) -> float:
"""当前注射器中的体积 (ml) 💧"""
return self._current_volume
@property
def max_velocity(self) -> float:
return self._max_velocity
@property
def transfer_rate(self) -> float:
return self._transfer_rate
@@ -103,17 +105,17 @@ class VirtualTransferPump:
"""设置最大速度 (ml/s) 🌊"""
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内
self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s")
def get_status(self) -> str:
"""获取泵状态 📋"""
return self._status
async def _simulate_operation(self, duration: float):
"""模拟操作延时 ⏱️"""
self._status = "Busy"
await self._ros_node.sleep(duration)
self._status = "Idle"
def _calculate_duration(self, volume: float, velocity: float = None) -> float:
"""
计算操作持续时间 ⏰
@@ -121,10 +123,10 @@ class VirtualTransferPump:
"""
if velocity is None:
velocity = self._max_velocity
# 📊 计算理论时间(用于日志显示)
theoretical_duration = abs(volume) / velocity
# 🚀 如果启用快速模式,使用固定的快速时间
if self._fast_mode:
# 根据操作类型选择快速时间
@@ -132,13 +134,13 @@ class VirtualTransferPump:
actual_duration = self._fast_move_time
else: # 很小的操作
actual_duration = 0.5
self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s")
return actual_duration
else:
# 正常模式使用理论时间
return theoretical_duration
def _calculate_display_duration(self, volume: float, velocity: float = None) -> float:
"""
计算显示用的持续时间(用于日志) 📊
@@ -147,16 +149,16 @@ class VirtualTransferPump:
if velocity is None:
velocity = self._max_velocity
return abs(volume) / velocity
# 新的set_position方法 - 专门用于SetPumpPosition动作
async def set_position(self, position: float, max_velocity: float = None):
"""
移动到绝对位置 - 专门用于SetPumpPosition动作 🎯
Args:
position (float): 目标位置 (ml)
max_velocity (float): 移动速度 (ml/s)
Returns:
dict: 符合SetPumpPosition.action定义的结果
"""
@@ -164,19 +166,19 @@ class VirtualTransferPump:
# 验证并转换参数
target_position = float(position)
velocity = float(max_velocity) if max_velocity is not None else self._max_velocity
# 限制位置在有效范围内
target_position = max(0.0, min(float(self.max_volume), target_position))
# 计算移动距离
volume_to_move = abs(target_position - self._position)
# 📊 计算显示用的时间(用于日志)
display_duration = self._calculate_display_duration(volume_to_move, velocity)
# ⚡ 计算实际执行时间(快速模式)
actual_duration = self._calculate_duration(volume_to_move, velocity)
# 🎯 确定操作类型和emoji
if target_position > self._position:
operation_type = "吸液"
@@ -187,28 +189,34 @@ class VirtualTransferPump:
else:
operation_type = "保持"
operation_emoji = "📍"
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)")
self.logger.info(
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
)
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
# 🚀 模拟移动过程
if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度
start_position = self._position
steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数
step_duration = actual_duration / steps
self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}")
for i in range(steps + 1):
# 计算当前位置和进度
progress = (i / steps) * 100 if steps > 0 else 100
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position
current_pos = (
start_position + (target_position - start_position) * (i / steps)
if steps > 0
else target_position
)
# 更新状态
if i < steps:
self._status = f"{operation_type}"
@@ -216,10 +224,10 @@ class VirtualTransferPump:
else:
self._status = "Idle"
status_emoji = ""
self._position = current_pos
self._current_volume = current_pos
# 显示进度每25%或最后一步)
if i == 0:
self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%")
@@ -227,7 +235,7 @@ class VirtualTransferPump:
self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%")
elif i == steps:
self.logger.info(f"{operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL")
# 等待一小步时间
if i < steps and step_duration > 0:
await self._ros_node.sleep(step_duration)
@@ -236,25 +244,27 @@ class VirtualTransferPump:
self._position = target_position
self._current_volume = target_position
self.logger.info(f" 📍 微调完成: {target_position:.2f}mL")
# 确保最终位置准确
self._position = target_position
self._current_volume = target_position
self._status = "Idle"
# 📊 最终状态日志
if volume_to_move > 0.01:
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
self.logger.info(
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
)
# 返回符合action定义的结果
return {
"success": True,
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
"final_position": self._position,
"final_volume": self._current_volume,
"operation_type": operation_type
"operation_type": operation_type,
}
except Exception as e:
error_msg = f"❌ 设置位置失败: {str(e)}"
self.logger.error(error_msg)
@@ -262,134 +272,136 @@ class VirtualTransferPump:
"success": False,
"message": error_msg,
"final_position": self._position,
"final_volume": self._current_volume
"final_volume": self._current_volume,
}
# 其他泵操作方法
async def pull_plunger(self, volume: float, velocity: float = None):
"""
拉取柱塞(吸液) 📥
Args:
volume (float): 要拉取的体积 (ml)
velocity (float): 拉取速度 (ml/s)
"""
new_position = min(self.max_volume, self._position + volume)
actual_volume = new_position - self._position
if actual_volume <= 0:
self.logger.warning("⚠️ 无法吸液 - 已达到最大容量")
return
display_duration = self._calculate_display_duration(actual_volume, velocity)
actual_duration = self._calculate_duration(actual_volume, velocity)
self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
await self._simulate_operation(actual_duration)
self._position = new_position
self._current_volume = new_position
self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
async def push_plunger(self, volume: float, velocity: float = None):
"""
推出柱塞(排液) 📤
Args:
volume (float): 要推出的体积 (ml)
velocity (float): 推出速度 (ml/s)
"""
new_position = max(0, self._position - volume)
actual_volume = self._position - new_position
if actual_volume <= 0:
self.logger.warning("⚠️ 无法排液 - 已达到最小容量")
return
display_duration = self._calculate_display_duration(actual_volume, velocity)
actual_duration = self._calculate_duration(actual_volume, velocity)
self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
await self._simulate_operation(actual_duration)
self._position = new_position
self._current_volume = new_position
self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
# 便捷操作方法
async def aspirate(self, volume: float, velocity: float = None):
"""吸液操作 📥"""
await self.pull_plunger(volume, velocity)
async def dispense(self, volume: float, velocity: float = None):
"""排液操作 📤"""
await self.push_plunger(volume, velocity)
async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None):
"""转移操作(先吸后排) 🔄"""
self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL")
# 吸液
await self.aspirate(volume, aspirate_velocity)
# 短暂停顿
self.logger.debug("⏸️ 短暂停顿...")
await self._ros_node.sleep(0.1)
# 排液
await self.dispense(volume, dispense_velocity)
async def empty_syringe(self, velocity: float = None):
"""清空注射器"""
await self.set_position(0, velocity)
async def fill_syringe(self, velocity: float = None):
"""充满注射器"""
await self.set_position(self.max_volume, velocity)
async def stop_operation(self):
"""停止当前操作"""
self._status = "Idle"
self.logger.info("Operation stopped")
# 状态查询方法
def get_position(self) -> float:
"""获取当前位置"""
return self._position
def get_current_volume(self) -> float:
"""获取当前体积"""
return self._current_volume
def get_remaining_capacity(self) -> float:
"""获取剩余容量"""
return self.max_volume - self._current_volume
def is_empty(self) -> bool:
"""检查是否为空"""
return self._current_volume <= 0.01 # 允许小量误差
def is_full(self) -> bool:
"""检查是否已满"""
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
def __str__(self):
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
return (
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
)
def __repr__(self):
return self.__str__()
@@ -398,20 +410,20 @@ class VirtualTransferPump:
async def demo():
"""虚拟泵使用示例"""
pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0})
await pump.initialize()
print(f"Initial state: {pump}")
# 测试set_position方法
result = await pump.set_position(10.0, max_velocity=2.0)
print(f"Set position result: {result}")
print(f"After setting position to 10ml: {pump}")
# 吸液测试
await pump.aspirate(5.0, velocity=2.0)
print(f"After aspirating 5ml: {pump}")
# 清空测试
result = await pump.set_position(0.0)
print(f"Empty result: {result}")

View File

@@ -0,0 +1,874 @@
"""
Virtual Workbench Device - 模拟工作台设备
包含:
- 1个机械臂 (每次操作3s, 独占锁)
- 3个加热台 (每次加热10s, 可并行)
工作流程:
1. A1-A5 物料同时启动, 竞争机械臂
2. 机械臂将物料移动到空闲加热台
3. 加热完成后, 机械臂将物料移动到C1-C5
注意: 调用来自线程池, 使用 threading.Lock 进行同步
"""
import logging
import time
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
from enum import Enum
from threading import Lock, RLock
from typing_extensions import TypedDict
from unilabos.registry.decorators import (
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
)
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
# ============ TypedDict 返回类型定义 ============
class MoveToHeatingStationResult(TypedDict):
"""move_to_heating_station 返回类型"""
success: bool
station_id: int
material_id: str
material_number: int
message: str
unilabos_samples: List[LabSample]
class StartHeatingResult(TypedDict):
"""start_heating 返回类型"""
success: bool
station_id: int
material_id: str
material_number: int
message: str
unilabos_samples: List[LabSample]
class MoveToOutputResult(TypedDict):
"""move_to_output 返回类型"""
success: bool
station_id: int
material_id: str
output_position: str
message: str
unilabos_samples: List[LabSample]
class PrepareMaterialsResult(TypedDict):
"""prepare_materials 返回类型 - 批量准备物料"""
success: bool
count: int
material_1: int # 物料编号1
material_2: int # 物料编号2
material_3: int # 物料编号3
material_4: int # 物料编号4
material_5: int # 物料编号5
message: str
unilabos_samples: List[LabSample]
# ============ 状态枚举 ============
class HeatingStationState(Enum):
"""加热台状态枚举"""
IDLE = "idle" # 空闲
OCCUPIED = "occupied" # 已放置物料, 等待加热
HEATING = "heating" # 加热中
COMPLETED = "completed" # 加热完成, 等待取走
class ArmState(Enum):
"""机械臂状态枚举"""
IDLE = "idle" # 空闲
BUSY = "busy" # 工作中
@dataclass
class HeatingStation:
"""加热台数据结构"""
station_id: int
state: HeatingStationState = HeatingStationState.IDLE
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
material_number: Optional[int] = None # 物料编号 (1-5)
heating_start_time: Optional[float] = None
heating_progress: float = 0.0
@device(
id="virtual_workbench",
category=["virtual_device"],
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
)
class VirtualWorkbench:
"""
Virtual Workbench Device - 虚拟工作台设备
模拟一个包含1个机械臂和3个加热台的工作站
- 机械臂操作耗时3秒, 同一时间只能执行一个操作
- 加热台加热耗时10秒, 3个加热台可并行工作
工作流:
1. 物料A1-A5并发启动(线程池), 竞争机械臂使用权
2. 获取机械臂后, 查找空闲加热台
3. 机械臂将物料放入加热台, 开始加热
4. 加热完成后, 机械臂将物料移动到目标位置Cn
"""
_ros_node: BaseROS2DeviceNode
# 配置常量
ARM_OPERATION_TIME: float = 2 # 机械臂操作时间(秒)
HEATING_TIME: float = 60.0 # 加热时间(秒)
NUM_HEATING_STATIONS: int = 3 # 加热台数量
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 "virtual_workbench"
self.config = config or {}
self.logger = logging.getLogger(f"VirtualWorkbench.{self.device_id}")
self.data: Dict[str, Any] = {}
# 从config中获取可配置参数
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
# 机械臂状态和锁
self._arm_lock = Lock()
self._arm_state = ArmState.IDLE
self._arm_current_task: Optional[str] = None
# 加热台状态
self._heating_stations: Dict[int, HeatingStation] = {
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
}
self._stations_lock = RLock()
# 任务追踪
self._active_tasks: Dict[str, Dict[str, Any]] = {}
self._tasks_lock = Lock()
# 处理其他kwargs参数
skip_keys = {"arm_operation_time", "heating_time", "num_heating_stations"}
for key, value in kwargs.items():
if key not in skip_keys and not hasattr(self, key):
setattr(self, key, value)
self.logger.info(f"=== 虚拟工作台 {self.device_id} 已创建 ===")
self.logger.info(
f"机械臂操作时间: {self.ARM_OPERATION_TIME}s | "
f"加热时间: {self.HEATING_TIME}s | "
f"加热台数量: {self.NUM_HEATING_STATIONS}"
)
@not_action
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点初始化后回调"""
self._ros_node = ros_node
@not_action
def initialize(self) -> bool:
"""初始化虚拟工作台"""
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
with self._stations_lock:
for station in self._heating_stations.values():
station.state = HeatingStationState.IDLE
station.current_material = None
station.material_number = None
station.heating_progress = 0.0
self.data.update(
{
"status": "Ready",
"arm_state": ArmState.IDLE.value,
"arm_current_task": None,
"heating_stations": self._get_stations_status(),
"active_tasks_count": 0,
"message": "工作台就绪",
}
)
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
return True
@not_action
def cleanup(self) -> bool:
"""清理虚拟工作台"""
self.logger.info(f"清理虚拟工作台 {self.device_id}")
self._arm_state = ArmState.IDLE
self._arm_current_task = None
with self._stations_lock:
self._heating_stations.clear()
with self._tasks_lock:
self._active_tasks.clear()
self.data.update(
{
"status": "Offline",
"arm_state": ArmState.IDLE.value,
"heating_stations": {},
"message": "工作台已关闭",
}
)
return True
def _get_stations_status(self) -> Dict[int, Dict[str, Any]]:
"""获取所有加热台状态"""
with self._stations_lock:
return {
station_id: {
"state": station.state.value,
"current_material": station.current_material,
"material_number": station.material_number,
"heating_progress": station.heating_progress,
}
for station_id, station in self._heating_stations.items()
}
def _update_data_status(self, message: Optional[str] = None):
"""更新状态数据"""
self.data.update(
{
"arm_state": self._arm_state.value,
"arm_current_task": self._arm_current_task,
"heating_stations": self._get_stations_status(),
"active_tasks_count": len(self._active_tasks),
}
)
if message:
self.data["message"] = message
def _find_available_heating_station(self) -> Optional[int]:
"""查找空闲的加热台"""
with self._stations_lock:
for station_id, station in self._heating_stations.items():
if station.state == HeatingStationState.IDLE:
return station_id
return None
def _acquire_arm(self, task_description: str) -> bool:
"""获取机械臂使用权(阻塞直到获取)"""
self.logger.info(f"[{task_description}] 等待获取机械臂...")
self._arm_lock.acquire()
self._arm_state = ArmState.BUSY
self._arm_current_task = task_description
self._update_data_status(f"机械臂执行: {task_description}")
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
return True
def _release_arm(self):
"""释放机械臂"""
task = self._arm_current_task
self._arm_state = ArmState.IDLE
self._arm_current_task = None
self._arm_lock.release()
self._update_data_status(f"机械臂已释放 (完成: {task})")
self.logger.info(f"机械臂已释放 (完成: {task})")
@action(
auto_prefix=True,
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
handles=[
ActionOutputHandle(key="channel_1", data_type="workbench_material",
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_2", data_type="workbench_material",
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_3", data_type="workbench_material",
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_4", data_type="workbench_material",
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_5", data_type="workbench_material",
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
],
)
def prepare_materials(
self,
sample_uuids: SampleUUIDsType,
count: int = 5,
) -> PrepareMaterialsResult:
"""
批量准备物料 - 虚拟起始节点
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
"""
materials = [i for i in range(1, count + 1)]
self.logger.info(
f"[准备物料] 生成 {count} 个物料: A1-A{count} -> material_1~material_{count}"
)
return {
"success": True,
"count": count,
"material_1": materials[0] if len(materials) > 0 else 0,
"material_2": materials[1] if len(materials) > 1 else 0,
"material_3": materials[2] if len(materials) > 2 else 0,
"material_4": materials[3] if len(materials) > 3 else 0,
"material_5": materials[4] if len(materials) > 4 else 0,
"message": f"已准备 {count} 个物料: A1-A{count}",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
)
for sample_uuid, content in sample_uuids.items()
],
}
@action(
auto_prefix=True,
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
handles=[
ActionInputHandle(key="material_input", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
],
)
def move_to_heating_station(
self,
sample_uuids: SampleUUIDsType,
material_number: int,
) -> MoveToHeatingStationResult:
"""
将物料从An位置移动到加热台
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
"""
material_id = f"A{material_number}"
task_desc = f"移动{material_id}到加热台"
self.logger.info(f"[任务] {task_desc} - 开始执行")
with self._tasks_lock:
self._active_tasks[material_id] = {
"status": "waiting_for_arm",
"start_time": time.time(),
}
try:
with self._tasks_lock:
self._active_tasks[material_id]["status"] = "waiting_for_arm"
self._acquire_arm(task_desc)
with self._tasks_lock:
self._active_tasks[material_id]["status"] = "finding_station"
station_id = None
while station_id is None:
station_id = self._find_available_heating_station()
if station_id is None:
self.logger.info(f"[{material_id}] 没有空闲加热台, 等待中...")
self._release_arm()
time.sleep(0.5)
self._acquire_arm(task_desc)
with self._stations_lock:
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
self._heating_stations[station_id].current_material = material_id
self._heating_stations[station_id].material_number = material_number
with self._tasks_lock:
self._active_tasks[material_id]["status"] = "arm_moving"
self._active_tasks[material_id]["assigned_station"] = station_id
self.logger.info(f"[{material_id}] 机械臂正在移动到加热台{station_id}...")
time.sleep(self.ARM_OPERATION_TIME)
self._update_data_status(f"{material_id}已放入加热台{station_id}")
self.logger.info(
f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)"
)
self._release_arm()
with self._tasks_lock:
self._active_tasks[material_id]["status"] = "placed_on_station"
return {
"success": True,
"station_id": station_id,
"material_id": material_id,
"material_number": material_number,
"message": f"{material_id}已成功移动到加热台{station_id}",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
except Exception as e:
self.logger.error(f"[{material_id}] 移动失败: {str(e)}")
if self._arm_lock.locked():
self._release_arm()
return {
"success": False,
"station_id": -1,
"material_id": material_id,
"material_number": material_number,
"message": f"移动失败: {str(e)}",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
@action(
auto_prefix=True,
always_free=True,
description="启动指定加热台的加热程序",
handles=[
ActionInputHandle(key="station_id_input", data_type="workbench_station",
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
ActionInputHandle(key="material_number_input", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
],
)
def start_heating(
self,
sample_uuids: SampleUUIDsType,
station_id: int,
material_number: int,
) -> StartHeatingResult:
"""
启动指定加热台的加热程序
"""
self.logger.info(f"[加热台{station_id}] 开始加热")
if station_id not in self._heating_stations:
return {
"success": False,
"station_id": station_id,
"material_id": "",
"material_number": material_number,
"message": f"无效的加热台ID: {station_id}",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
with self._stations_lock:
station = self._heating_stations[station_id]
if station.current_material is None:
return {
"success": False,
"station_id": station_id,
"material_id": "",
"material_number": material_number,
"message": f"加热台{station_id}上没有物料",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
if station.state == HeatingStationState.HEATING:
return {
"success": False,
"station_id": station_id,
"material_id": station.current_material,
"material_number": material_number,
"message": f"加热台{station_id}已经在加热中",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
material_id = station.current_material
station.state = HeatingStationState.HEATING
station.heating_start_time = time.time()
station.heating_progress = 0.0
with self._tasks_lock:
if material_id in self._active_tasks:
self._active_tasks[material_id]["status"] = "heating"
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
with self._stations_lock:
heating_list = [
f"加热台{sid}:{s.current_material}"
for sid, s in self._heating_stations.items()
if s.state == HeatingStationState.HEATING and s.current_material
]
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
start_time = time.time()
last_countdown_log = start_time
while True:
elapsed = time.time() - start_time
remaining = max(0.0, self.HEATING_TIME - elapsed)
progress = min(100.0, (elapsed / self.HEATING_TIME) * 100)
with self._stations_lock:
self._heating_stations[station_id].heating_progress = progress
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
if time.time() - last_countdown_log >= 5.0:
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
last_countdown_log = time.time()
if elapsed >= self.HEATING_TIME:
break
time.sleep(1.0)
with self._stations_lock:
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
self._heating_stations[station_id].heating_progress = 100.0
with self._tasks_lock:
if material_id in self._active_tasks:
self._active_tasks[material_id]["status"] = "heating_completed"
self._update_data_status(f"加热台{station_id}加热完成")
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
return {
"success": True,
"station_id": station_id,
"material_id": material_id,
"material_number": material_number,
"message": f"加热台{station_id}加热完成",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
@action(
auto_prefix=True,
description="将物料从加热台移动到输出位置Cn",
handles=[
ActionInputHandle(key="output_station_input", data_type="workbench_station",
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
ActionInputHandle(key="output_material_input", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
],
)
def move_to_output(
self,
sample_uuids: SampleUUIDsType,
station_id: int,
material_number: int,
) -> MoveToOutputResult:
"""
将物料从加热台移动到输出位置Cn
"""
output_number = material_number
if station_id not in self._heating_stations:
return {
"success": False,
"station_id": station_id,
"material_id": "",
"output_position": f"C{output_number}",
"message": f"无效的加热台ID: {station_id}",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
with self._stations_lock:
station = self._heating_stations[station_id]
material_id = station.current_material
if material_id is None:
return {
"success": False,
"station_id": station_id,
"material_id": "",
"output_position": f"C{output_number}",
"message": f"加热台{station_id}上没有物料",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
if station.state != HeatingStationState.COMPLETED:
return {
"success": False,
"station_id": station_id,
"material_id": material_id,
"output_position": f"C{output_number}",
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
output_position = f"C{output_number}"
task_desc = f"从加热台{station_id}移动{material_id}{output_position}"
self.logger.info(f"[任务] {task_desc}")
try:
with self._tasks_lock:
if material_id in self._active_tasks:
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
self._acquire_arm(task_desc)
with self._tasks_lock:
if material_id in self._active_tasks:
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
self.logger.info(
f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}..."
)
time.sleep(self.ARM_OPERATION_TIME)
with self._stations_lock:
self._heating_stations[station_id].state = HeatingStationState.IDLE
self._heating_stations[station_id].current_material = None
self._heating_stations[station_id].material_number = None
self._heating_stations[station_id].heating_progress = 0.0
self._heating_stations[station_id].heating_start_time = None
self._release_arm()
with self._tasks_lock:
if material_id in self._active_tasks:
self._active_tasks[material_id]["status"] = "completed"
self._active_tasks[material_id]["end_time"] = time.time()
self._update_data_status(f"{material_id}已移动到{output_position}")
self.logger.info(
f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)"
)
return {
"success": True,
"station_id": station_id,
"material_id": material_id,
"output_position": output_position,
"message": f"{material_id}已成功移动到{output_position}",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str)
else (content.serialize() if content is not None else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
except Exception as e:
self.logger.error(f"移动到输出位置失败: {str(e)}")
if self._arm_lock.locked():
self._release_arm()
return {
"success": False,
"station_id": station_id,
"material_id": "",
"output_position": output_position,
"message": f"移动失败: {str(e)}",
"unilabos_samples": [
LabSample(
sample_uuid=sample_uuid,
oss_path="",
extra=(
{"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {})
),
)
for sample_uuid, content in sample_uuids.items()
],
}
# ============ 状态属性 ============
@property
@topic_config()
def status(self) -> str:
return self.data.get("status", "Unknown")
@property
@topic_config()
def arm_state(self) -> str:
return self._arm_state.value
@property
@topic_config()
def arm_current_task(self) -> str:
return self._arm_current_task or ""
@property
@topic_config()
def heating_station_1_state(self) -> str:
with self._stations_lock:
station = self._heating_stations.get(1)
return station.state.value if station else "unknown"
@property
@topic_config()
def heating_station_1_material(self) -> str:
with self._stations_lock:
station = self._heating_stations.get(1)
return station.current_material or "" if station else ""
@property
@topic_config()
def heating_station_1_progress(self) -> float:
with self._stations_lock:
station = self._heating_stations.get(1)
return station.heating_progress if station else 0.0
@property
@topic_config()
def heating_station_2_state(self) -> str:
with self._stations_lock:
station = self._heating_stations.get(2)
return station.state.value if station else "unknown"
@property
@topic_config()
def heating_station_2_material(self) -> str:
with self._stations_lock:
station = self._heating_stations.get(2)
return station.current_material or "" if station else ""
@property
@topic_config()
def heating_station_2_progress(self) -> float:
with self._stations_lock:
station = self._heating_stations.get(2)
return station.heating_progress if station else 0.0
@property
@topic_config()
def heating_station_3_state(self) -> str:
with self._stations_lock:
station = self._heating_stations.get(3)
return station.state.value if station else "unknown"
@property
@topic_config()
def heating_station_3_material(self) -> str:
with self._stations_lock:
station = self._heating_stations.get(3)
return station.current_material or "" if station else ""
@property
@topic_config()
def heating_station_3_progress(self) -> float:
with self._stations_lock:
station = self._heating_stations.get(3)
return station.heating_progress if station else 0.0
@property
@topic_config()
def active_tasks_count(self) -> int:
with self._tasks_lock:
return len(self._active_tasks)
@property
@topic_config()
def message(self) -> str:
return self.data.get("message", "")

View File

@@ -0,0 +1 @@
# PRCXI 耗材管理 Web 应用

View File

@@ -0,0 +1,4 @@
"""启动入口: python -m unilabos.labware_manager"""
from unilabos.labware_manager.app import main
main()

View File

@@ -0,0 +1,196 @@
"""FastAPI 应用 + CRUD API + 启动入口。
用法: python -m unilabos.labware_manager.app
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from unilabos.labware_manager.models import LabwareDB, LabwareItem
_HERE = Path(__file__).resolve().parent
_DB_PATH = _HERE / "labware_db.json"
app = FastAPI(title="PRCXI 耗材管理", version="1.0")
# 静态文件 + 模板
app.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
templates = Jinja2Templates(directory=str(_HERE / "templates"))
# ---------- DB 读写 ----------
def _load_db() -> LabwareDB:
if not _DB_PATH.exists():
return LabwareDB()
with open(_DB_PATH, "r", encoding="utf-8") as f:
return LabwareDB(**json.load(f))
def _save_db(db: LabwareDB) -> None:
with open(_DB_PATH, "w", encoding="utf-8") as f:
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
# ---------- 页面路由 ----------
@app.get("/", response_class=HTMLResponse)
async def index_page(request: Request):
db = _load_db()
# 按 type 分组
groups = {}
for item in db.items:
groups.setdefault(item.type, []).append(item)
return templates.TemplateResponse("index.html", {
"request": request,
"groups": groups,
"total": len(db.items),
})
@app.get("/labware/new", response_class=HTMLResponse)
async def new_page(request: Request, type: str = "plate"):
return templates.TemplateResponse("edit.html", {
"request": request,
"item": None,
"labware_type": type,
"is_new": True,
})
@app.get("/labware/{item_id}", response_class=HTMLResponse)
async def detail_page(request: Request, item_id: str):
db = _load_db()
item = _find_item(db, item_id)
if not item:
raise HTTPException(404, "耗材不存在")
return templates.TemplateResponse("detail.html", {
"request": request,
"item": item,
})
@app.get("/labware/{item_id}/edit", response_class=HTMLResponse)
async def edit_page(request: Request, item_id: str):
db = _load_db()
item = _find_item(db, item_id)
if not item:
raise HTTPException(404, "耗材不存在")
return templates.TemplateResponse("edit.html", {
"request": request,
"item": item,
"labware_type": item.type,
"is_new": False,
})
# ---------- API 端点 ----------
@app.get("/api/labware")
async def api_list_labware():
db = _load_db()
return {"items": [item.model_dump() for item in db.items]}
@app.post("/api/labware")
async def api_create_labware(request: Request):
data = await request.json()
db = _load_db()
item = LabwareItem(**data)
# 确保 id 唯一
existing_ids = {it.id for it in db.items}
while item.id in existing_ids:
import uuid
item.id = uuid.uuid4().hex[:8]
db.items.append(item)
_save_db(db)
return {"status": "ok", "id": item.id}
@app.put("/api/labware/{item_id}")
async def api_update_labware(item_id: str, request: Request):
data = await request.json()
db = _load_db()
for i, it in enumerate(db.items):
if it.id == item_id or it.function_name == item_id:
updated = LabwareItem(**{**it.model_dump(), **data, "id": it.id})
db.items[i] = updated
_save_db(db)
return {"status": "ok", "id": it.id}
raise HTTPException(404, "耗材不存在")
@app.delete("/api/labware/{item_id}")
async def api_delete_labware(item_id: str):
db = _load_db()
original_len = len(db.items)
db.items = [it for it in db.items if it.id != item_id and it.function_name != item_id]
if len(db.items) == original_len:
raise HTTPException(404, "耗材不存在")
_save_db(db)
return {"status": "ok"}
@app.post("/api/generate-code")
async def api_generate_code(request: Request):
body = await request.json() if await request.body() else {}
test_mode = body.get("test_mode", True)
db = _load_db()
if not db.items:
raise HTTPException(400, "数据库为空,请先导入")
from unilabos.labware_manager.codegen import generate_code
from unilabos.labware_manager.yaml_gen import generate_yaml
py_path = generate_code(db, test_mode=test_mode)
yaml_paths = generate_yaml(db, test_mode=test_mode)
return {
"status": "ok",
"python_file": str(py_path),
"yaml_files": [str(p) for p in yaml_paths],
"test_mode": test_mode,
}
@app.post("/api/import-from-code")
async def api_import_from_code():
from unilabos.labware_manager.importer import import_from_code, save_db
db = import_from_code()
save_db(db)
return {
"status": "ok",
"count": len(db.items),
"items": [{"function_name": it.function_name, "type": it.type} for it in db.items],
}
# ---------- 辅助函数 ----------
def _find_item(db: LabwareDB, item_id: str) -> Optional[LabwareItem]:
for item in db.items:
if item.id == item_id or item.function_name == item_id:
return item
return None
# ---------- 启动入口 ----------
def main():
import uvicorn
port = int(os.environ.get("LABWARE_PORT", "8010"))
print(f"PRCXI 耗材管理 → http://localhost:{port}")
uvicorn.run(app, host="0.0.0.0", port=port)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,451 @@
"""JSON → prcxi_labware.py 代码生成。
读取 labware_db.json输出完整的 prcxi_labware.py或 prcxi_labware_test.py
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import List, Optional
from unilabos.labware_manager.models import LabwareDB, LabwareItem
_TARGET_DIR = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi"
# ---------- 固定头部 ----------
_HEADER = '''\
from typing import Any, Callable, Dict, List, Optional, Tuple
from pylabrobot.resources import Tube, Coordinate
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
from pylabrobot.resources.tip import Tip, TipCreator
from pylabrobot.resources.tip_rack import TipRack, TipSpot
from pylabrobot.resources.utils import create_ordered_items_2d
from pylabrobot.resources.height_volume_functions import (
compute_height_from_volume_rectangle,
compute_volume_from_height_rectangle,
)
from .prcxi import PRCXI9300Plate, PRCXI9300TipRack, PRCXI9300Trash, PRCXI9300TubeRack, PRCXI9300PlateAdapter
def _make_tip_helper(volume: float, length: float, depth: float) -> Tip:
"""
PLR 的 Tip 类参数名为: maximal_volume, total_tip_length, fitting_depth
"""
return Tip(
has_filter=False, # 默认无滤芯
maximal_volume=volume,
total_tip_length=length,
fitting_depth=depth
)
'''
def _gen_plate(item: LabwareItem) -> str:
"""生成 Plate 类型的工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
has_vf = item.volume_functions is not None
if has_vf:
# 有 volume_functions 时需要 well_kwargs 方式
vf = item.volume_functions
well = item.well
grid = item.grid
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
# 计算 well_size 变量
lines.append(f' well_size_x = {well.size_x}')
lines.append(f' well_size_y = {well.size_y}')
lines.append(f' well_kwargs = {{')
lines.append(f' "size_x": well_size_x,')
lines.append(f' "size_y": well_size_y,')
lines.append(f' "size_z": {well.size_z},')
lines.append(f' "bottom_type": WellBottomType.{well.bottom_type},')
if well.cross_section_type and well.cross_section_type != "CIRCLE":
lines.append(f' "cross_section_type": CrossSectionType.{well.cross_section_type},')
lines.append(f' "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(')
lines.append(f' liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y')
lines.append(f' ),')
lines.append(f' "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(')
lines.append(f' liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y')
lines.append(f' ),')
if well.material_z_thickness is not None:
lines.append(f' "material_z_thickness": {well.material_z_thickness},')
lines.append(f' }}')
lines.append(f'')
lines.append(f' return PRCXI9300Plate(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
lines.append(f' lid=None,')
lines.append(f' model="{item.model}",')
lines.append(f' category="plate",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
lines.append(f' ordered_items=create_ordered_items_2d(')
lines.append(f' Well,')
lines.append(f' num_items_x={grid.num_items_x},')
lines.append(f' num_items_y={grid.num_items_y},')
lines.append(f' dx={grid.dx},')
lines.append(f' dy={grid.dy},')
lines.append(f' dz={grid.dz},')
lines.append(f' item_dx={grid.item_dx},')
lines.append(f' item_dy={grid.item_dy},')
lines.append(f' **well_kwargs,')
lines.append(f' ),')
lines.append(f' )')
else:
# 普通 plate
well = item.well
grid = item.grid
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
lines.append(f' return PRCXI9300Plate(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
if item.plate_type:
lines.append(f' plate_type="{item.plate_type}",')
lines.append(f' model="{item.model}",')
lines.append(f' category="plate",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
if grid and well:
lines.append(f' ordered_items=create_ordered_items_2d(')
lines.append(f' Well,')
lines.append(f' num_items_x={grid.num_items_x},')
lines.append(f' num_items_y={grid.num_items_y},')
lines.append(f' dx={grid.dx},')
lines.append(f' dy={grid.dy},')
lines.append(f' dz={grid.dz},')
lines.append(f' item_dx={grid.item_dx},')
lines.append(f' item_dy={grid.item_dy},')
lines.append(f' size_x={well.size_x},')
lines.append(f' size_y={well.size_y},')
lines.append(f' size_z={well.size_z},')
if well.max_volume is not None:
lines.append(f' max_volume={well.max_volume},')
if well.material_z_thickness is not None:
lines.append(f' material_z_thickness={well.material_z_thickness},')
if well.bottom_type and well.bottom_type != "FLAT":
lines.append(f' bottom_type=WellBottomType.{well.bottom_type},')
if well.cross_section_type:
lines.append(f' cross_section_type=CrossSectionType.{well.cross_section_type},')
lines.append(f' ),')
lines.append(f' )')
return '\n'.join(lines)
def _gen_tip_rack(item: LabwareItem) -> str:
"""生成 TipRack 工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
grid = item.grid
tip = item.tip
lines.append(f'def {fn}(name: str) -> PRCXI9300TipRack:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
lines.append(f' return PRCXI9300TipRack(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
lines.append(f' model="{item.model}",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
if grid and tip:
lines.append(f' ordered_items=create_ordered_items_2d(')
lines.append(f' TipSpot,')
lines.append(f' num_items_x={grid.num_items_x},')
lines.append(f' num_items_y={grid.num_items_y},')
lines.append(f' dx={grid.dx},')
lines.append(f' dy={grid.dy},')
lines.append(f' dz={grid.dz},')
lines.append(f' item_dx={grid.item_dx},')
lines.append(f' item_dy={grid.item_dy},')
lines.append(f' size_x={tip.spot_size_x},')
lines.append(f' size_y={tip.spot_size_y},')
lines.append(f' size_z={tip.spot_size_z},')
lines.append(f' make_tip=lambda: _make_tip_helper(volume={tip.tip_volume}, length={tip.tip_length}, depth={tip.tip_fitting_depth})')
lines.append(f' )')
lines.append(f' )')
return '\n'.join(lines)
def _gen_trash(item: LabwareItem) -> str:
"""生成 Trash 工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
lines.append(f'def {fn}(name: str = "trash") -> PRCXI9300Trash:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
lines.append(f' return PRCXI9300Trash(')
lines.append(f' name="trash",')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
lines.append(f' category="trash",')
lines.append(f' model="{item.model}",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
lines.append(f' )')
return '\n'.join(lines)
def _gen_tube_rack(item: LabwareItem) -> str:
"""生成 TubeRack 工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
grid = item.grid
tube = item.tube
lines.append(f'def {fn}(name: str) -> PRCXI9300TubeRack:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
lines.append(f' return PRCXI9300TubeRack(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
lines.append(f' model="{item.model}",')
lines.append(f' category="tube_rack",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
if grid and tube:
lines.append(f' ordered_items=create_ordered_items_2d(')
lines.append(f' Tube,')
lines.append(f' num_items_x={grid.num_items_x},')
lines.append(f' num_items_y={grid.num_items_y},')
lines.append(f' dx={grid.dx},')
lines.append(f' dy={grid.dy},')
lines.append(f' dz={grid.dz},')
lines.append(f' item_dx={grid.item_dx},')
lines.append(f' item_dy={grid.item_dy},')
lines.append(f' size_x={tube.size_x},')
lines.append(f' size_y={tube.size_y},')
lines.append(f' size_z={tube.size_z},')
lines.append(f' max_volume={tube.max_volume}')
lines.append(f' )')
lines.append(f' )')
return '\n'.join(lines)
def _gen_plate_adapter(item: LabwareItem) -> str:
"""生成 PlateAdapter 工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
lines.append(f'def {fn}(name: str) -> PRCXI9300PlateAdapter:')
lines.append(f' """ {doc} """')
lines.append(f' return PRCXI9300PlateAdapter(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
if item.model:
lines.append(f' model="{item.model}",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
lines.append(f' )')
return '\n'.join(lines)
def _fmt_dict(d: dict) -> str:
"""格式化字典为 Python 代码片段。"""
parts = []
for k, v in d.items():
if isinstance(v, str):
parts.append(f'"{k}": "{v}"')
elif v is None:
continue
else:
parts.append(f'"{k}": {v}')
return '{' + ', '.join(parts) + '}'
def _gen_template_factory_kinds(items: List[LabwareItem]) -> str:
"""生成 PRCXI_TEMPLATE_FACTORY_KINDS 列表。"""
lines = ['PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [']
for item in items:
if item.include_in_template_matching and item.template_kind:
lines.append(f' ({item.function_name}, "{item.template_kind}"),')
lines.append(']')
return '\n'.join(lines)
def _gen_footer() -> str:
"""生成文件尾部的模板相关代码。"""
return '''
# ---------------------------------------------------------------------------
# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配)
# ---------------------------------------------------------------------------
_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None
def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any:
probe = "__unilab_template_probe__"
if factory.__name__ == "PRCXI_trash":
return factory()
return factory(probe)
def _first_child_capacity_for_match(resource: Any) -> float:
"""Well max_volume 或 Tip 的 maximal_volume用于与设备端 Volume 类似的打分。"""
ch = getattr(resource, "children", None) or []
if not ch:
return 0.0
c0 = ch[0]
mv = getattr(c0, "max_volume", None)
if mv is not None:
return float(mv)
tip = getattr(c0, "tip", None)
if tip is not None:
mv2 = getattr(tip, "maximal_volume", None)
if mv2 is not None:
return float(mv2)
return 0.0
def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]:
"""返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。"""
global _PRCXI_TEMPLATE_SPECS_CACHE
if _PRCXI_TEMPLATE_SPECS_CACHE is not None:
return _PRCXI_TEMPLATE_SPECS_CACHE
out: List[Dict[str, Any]] = []
for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS:
try:
r = _probe_prcxi_resource(factory)
except Exception:
continue
nx = int(getattr(r, "num_items_x", None) or 0)
ny = int(getattr(r, "num_items_y", None) or 0)
nchild = len(getattr(r, "children", []) or [])
hole_count = nx * ny if nx > 0 and ny > 0 else nchild
hole_row = ny if nx > 0 and ny > 0 else 0
hole_col = nx if nx > 0 and ny > 0 else 0
mi = getattr(r, "material_info", None) or {}
vol = _first_child_capacity_for_match(r)
menum = mi.get("materialEnum")
if menum is None and kind == "tip_rack":
menum = 1
elif menum is None and kind == "trash":
menum = 6
out.append(
{
"class_name": factory.__name__,
"kind": kind,
"materialEnum": menum,
"HoleRow": hole_row,
"HoleColum": hole_col,
"Volume": vol,
"hole_count": hole_count,
"material_uuid": mi.get("uuid"),
"material_code": mi.get("Code"),
}
)
_PRCXI_TEMPLATE_SPECS_CACHE = out
return out
'''
def generate_code(db: LabwareDB, test_mode: bool = True) -> Path:
"""生成 prcxi_labware.py (或 _test.py),返回输出文件路径。"""
suffix = "_test" if test_mode else ""
out_path = _TARGET_DIR / f"prcxi_labware{suffix}.py"
# 备份
if out_path.exists():
bak = out_path.with_suffix(".py.bak")
shutil.copy2(out_path, bak)
# 按类型分组的生成器
generators = {
"plate": _gen_plate,
"tip_rack": _gen_tip_rack,
"trash": _gen_trash,
"tube_rack": _gen_tube_rack,
"plate_adapter": _gen_plate_adapter,
}
# 按 type 分段
sections = {
"plate": [],
"tip_rack": [],
"trash": [],
"tube_rack": [],
"plate_adapter": [],
}
for item in db.items:
gen = generators.get(item.type)
if gen:
sections[item.type].append(gen(item))
# 组装完整文件
parts = [_HEADER]
section_titles = {
"plate": "# =========================================================================\n# Plates\n# =========================================================================",
"tip_rack": "# =========================================================================\n# Tip Racks\n# =========================================================================",
"trash": "# =========================================================================\n# Trash\n# =========================================================================",
"tube_rack": "# =========================================================================\n# Tube Racks\n# =========================================================================",
"plate_adapter": "# =========================================================================\n# Plate Adapters\n# =========================================================================",
}
for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]:
if sections[type_key]:
parts.append(section_titles[type_key])
for code in sections[type_key]:
parts.append(code)
# Template factory kinds
parts.append("")
parts.append(_gen_template_factory_kinds(db.items))
# Footer
parts.append(_gen_footer())
content = '\n'.join(parts)
out_path.write_text(content, encoding="utf-8")
return out_path
if __name__ == "__main__":
from unilabos.labware_manager.importer import load_db
db = load_db()
if not db.items:
print("labware_db.json 为空,请先运行 importer.py")
else:
out = generate_code(db, test_mode=True)
print(f"已生成 {out} ({len(db.items)} 个工厂函数)")

View File

@@ -0,0 +1,474 @@
"""从现有 prcxi_labware.py + registry YAML 导入耗材数据到 labware_db.json。
策略:
1. 实例化每个工厂函数 → 提取物理尺寸、material_info、children
2. AST 解析源码 → 提取 docstring、volume_function 参数、plate_type
3. 从 children[0].location 反推 dx/dy/dz相邻位置差推 item_dx/item_dy
4. 同时读取现有 YAML → 提取 registry_category / description
"""
from __future__ import annotations
import ast
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
# 将项目根目录加入 sys.path 以便 import
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from unilabos.labware_manager.models import (
AdapterInfo,
GridInfo,
LabwareDB,
LabwareItem,
MaterialInfo,
TipInfo,
TubeInfo,
VolumeFunctions,
WellInfo,
)
# ---------- 路径常量 ----------
_LABWARE_PY = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi" / "prcxi_labware.py"
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
_DB_PATH = Path(__file__).resolve().parent / "labware_db.json"
# YAML 文件名 → type 映射
_YAML_MAP: Dict[str, str] = {
"plates.yaml": "plate",
"tip_racks.yaml": "tip_rack",
"trash.yaml": "trash",
"tube_racks.yaml": "tube_rack",
"plate_adapters.yaml": "plate_adapter",
}
# PRCXI_TEMPLATE_FACTORY_KINDS 中列出的函数名include_in_template_matching=True
_TEMPLATE_FACTORY_NAMES = {
"PRCXI_BioER_96_wellplate", "PRCXI_nest_1_troughplate",
"PRCXI_BioRad_384_wellplate", "PRCXI_AGenBio_4_troughplate",
"PRCXI_nest_12_troughplate", "PRCXI_CellTreat_96_wellplate",
"PRCXI_10ul_eTips", "PRCXI_300ul_Tips",
"PRCXI_PCR_Plate_200uL_nonskirted", "PRCXI_PCR_Plate_200uL_semiskirted",
"PRCXI_PCR_Plate_200uL_skirted", "PRCXI_trash",
"PRCXI_96_DeepWell", "PRCXI_EP_Adapter",
"PRCXI_1250uL_Tips", "PRCXI_10uL_Tips",
"PRCXI_1000uL_Tips", "PRCXI_200uL_Tips",
"PRCXI_48_DeepWell",
}
# template_kind 对应
_TEMPLATE_KINDS: Dict[str, str] = {
"PRCXI_BioER_96_wellplate": "plate",
"PRCXI_nest_1_troughplate": "plate",
"PRCXI_BioRad_384_wellplate": "plate",
"PRCXI_AGenBio_4_troughplate": "plate",
"PRCXI_nest_12_troughplate": "plate",
"PRCXI_CellTreat_96_wellplate": "plate",
"PRCXI_10ul_eTips": "tip_rack",
"PRCXI_300ul_Tips": "tip_rack",
"PRCXI_PCR_Plate_200uL_nonskirted": "plate",
"PRCXI_PCR_Plate_200uL_semiskirted": "plate",
"PRCXI_PCR_Plate_200uL_skirted": "plate",
"PRCXI_trash": "trash",
"PRCXI_96_DeepWell": "plate",
"PRCXI_EP_Adapter": "tube_rack",
"PRCXI_1250uL_Tips": "tip_rack",
"PRCXI_10uL_Tips": "tip_rack",
"PRCXI_1000uL_Tips": "tip_rack",
"PRCXI_200uL_Tips": "tip_rack",
"PRCXI_48_DeepWell": "plate",
}
def _load_registry_info() -> Dict[str, Dict[str, Any]]:
"""读取所有 registry YAML 文件,返回 {function_name: {category, description}} 映射。"""
info: Dict[str, Dict[str, Any]] = {}
for fname, ltype in _YAML_MAP.items():
fpath = _REGISTRY_DIR / fname
if not fpath.exists():
continue
with open(fpath, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
for func_name, entry in data.items():
info[func_name] = {
"registry_category": entry.get("category", ["prcxi", ltype.replace("plate_adapter", "plate_adapters")]),
"registry_description": entry.get("description", ""),
}
return info
def _parse_ast_info() -> Dict[str, Dict[str, Any]]:
"""AST 解析 prcxi_labware.py提取每个工厂函数的 docstring 和 volume_function 参数。"""
source = _LABWARE_PY.read_text(encoding="utf-8")
tree = ast.parse(source)
result: Dict[str, Dict[str, Any]] = {}
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
fname = node.name
if not fname.startswith("PRCXI_"):
continue
if fname.startswith("_"):
continue
info: Dict[str, Any] = {"docstring": "", "volume_functions": None, "plate_type": None}
# docstring
doc = ast.get_docstring(node)
if doc:
info["docstring"] = doc.strip()
# 搜索函数体中的 plate_type 赋值和 volume_function 参数
func_source = ast.get_source_segment(source, node) or ""
# plate_type
m = re.search(r'plate_type\s*=\s*["\']([^"\']+)["\']', func_source)
if m:
info["plate_type"] = m.group(1)
# volume_functions: 检查 compute_height_from_volume_rectangle
if "compute_height_from_volume_rectangle" in func_source:
# 提取 well_length 和 well_width
vf: Dict[str, Any] = {"type": "rectangle"}
# 尝试从 lambda 中提取
wl_match = re.search(r'well_length\s*=\s*([\w_.]+)', func_source)
ww_match = re.search(r'well_width\s*=\s*([\w_.]+)', func_source)
if wl_match:
vf["well_length_var"] = wl_match.group(1)
if ww_match:
vf["well_width_var"] = ww_match.group(1)
info["volume_functions"] = vf
result[fname] = info
return result
def _probe_factory(factory_func) -> Any:
"""实例化工厂函数获取 resource 对象。"""
if factory_func.__name__ == "PRCXI_trash":
return factory_func()
return factory_func("__probe__")
def _get_size(resource, attr: str) -> float:
"""获取 PLR Resource 的尺寸(兼容 size_x 和 _size_x"""
val = getattr(resource, attr, None)
if val is None:
val = getattr(resource, f"_{attr}", None)
if val is None:
val = getattr(resource, f"get_{attr}", lambda: 0)()
return float(val or 0)
def _extract_grid_from_children(resource) -> Optional[Dict[str, Any]]:
"""从 resource.children 提取网格信息。"""
children = getattr(resource, "children", None) or []
if not children:
return None
# 获取 num_items_x, num_items_y
num_x = getattr(resource, "num_items_x", None)
num_y = getattr(resource, "num_items_y", None)
if num_x is None or num_y is None:
return None
c0 = children[0]
loc0 = getattr(c0, "location", None)
dx = loc0.x if loc0 else 0.0
dy_raw = loc0.y if loc0 else 0.0 # 这是 PLR 布局后的位置,不是输入参数
dz = loc0.z if loc0 else 0.0
# 推算 item_dx, item_dy
item_dx = 9.0
item_dy = 9.0
if len(children) > 1:
c1 = children[1]
loc1 = getattr(c1, "location", None)
if loc1 and loc0:
diff_x = abs(loc1.x - loc0.x)
diff_y = abs(loc1.y - loc0.y)
if diff_x > 0.1:
item_dx = diff_x
if diff_y > 0.1:
item_dy = diff_y
# 如果 num_items_y > 1 且 num_items_x > 1, 找列间距
if int(num_y) > 1 and int(num_x) > 1 and len(children) >= int(num_y) + 1:
cn = children[int(num_y)]
locn = getattr(cn, "location", None)
if locn and loc0:
col_diff = abs(locn.x - loc0.x)
row_diff = abs(children[1].location.y - loc0.y) if len(children) > 1 else item_dy
if col_diff > 0.1:
item_dx = col_diff
if row_diff > 0.1:
item_dy = row_diff
# PLR create_ordered_items_2d 的 Y 轴排列是倒序的:
# child[0].y = dy_param + (num_y - 1) * item_dy (最上面一行)
# 因此反推原始 dy 参数:
dy = dy_raw - (int(num_y) - 1) * item_dy
return {
"num_items_x": int(num_x),
"num_items_y": int(num_y),
"dx": round(dx, 4),
"dy": round(dy, 4),
"dz": round(dz, 4),
"item_dx": round(item_dx, 4),
"item_dy": round(item_dy, 4),
}
def _extract_well_info(child) -> Dict[str, Any]:
"""从 Well/TipSpot/Tube 子对象提取信息。"""
# material_z_thickness 在 PLR 中如果未设置会抛 NotImplementedError
mzt = None
try:
mzt = child.material_z_thickness
except (NotImplementedError, AttributeError):
mzt = getattr(child, "_material_z_thickness", None)
return {
"size_x": round(_get_size(child, "size_x"), 4),
"size_y": round(_get_size(child, "size_y"), 4),
"size_z": round(_get_size(child, "size_z"), 4),
"max_volume": getattr(child, "max_volume", None),
"bottom_type": getattr(child, "bottom_type", None),
"cross_section_type": getattr(child, "cross_section_type", None),
"material_z_thickness": mzt,
}
def import_from_code() -> LabwareDB:
"""执行完整的导入流程,返回 LabwareDB 对象。"""
# 1. 加载 registry 信息
reg_info = _load_registry_info()
# 2. AST 解析源码
ast_info = _parse_ast_info()
# 3. 导入工厂模块(通过包路径避免 relative import 问题)
import importlib
mod = importlib.import_module("unilabos.devices.liquid_handling.prcxi.prcxi_labware")
# 4. 获取 PRCXI_TEMPLATE_FACTORY_KINDS 列出的函数
factory_kinds = getattr(mod, "PRCXI_TEMPLATE_FACTORY_KINDS", [])
template_func_names = {f.__name__ for f, _k in factory_kinds}
# 5. 收集所有 PRCXI_ 开头的工厂函数
all_factories: List[Tuple[str, Any]] = []
for attr_name in dir(mod):
if attr_name.startswith("PRCXI_") and not attr_name.startswith("_"):
obj = getattr(mod, attr_name)
if callable(obj) and not isinstance(obj, type):
all_factories.append((attr_name, obj))
# 按源码行号排序
all_factories.sort(key=lambda x: getattr(x[1], "__code__", None) and x[1].__code__.co_firstlineno or 0)
items: List[LabwareItem] = []
for func_name, factory in all_factories:
try:
resource = _probe_factory(factory)
except Exception as e:
print(f"跳过 {func_name}: {e}")
continue
# 确定类型
type_name = "plate"
class_name = type(resource).__name__
if "TipRack" in class_name:
type_name = "tip_rack"
elif "Trash" in class_name:
type_name = "trash"
elif "TubeRack" in class_name:
type_name = "tube_rack"
elif "PlateAdapter" in class_name:
type_name = "plate_adapter"
# material_info
state = getattr(resource, "_unilabos_state", {}) or {}
mat = state.get("Material", {})
mat_info = MaterialInfo(
uuid=mat.get("uuid", ""),
Code=mat.get("Code", ""),
Name=mat.get("Name", ""),
materialEnum=mat.get("materialEnum"),
SupplyType=mat.get("SupplyType"),
)
# AST 信息
ast_data = ast_info.get(func_name, {})
docstring = ast_data.get("docstring", "")
plate_type = ast_data.get("plate_type")
# Registry 信息
reg = reg_info.get(func_name, {})
registry_category = reg.get("registry_category", ["prcxi", _type_to_yaml_subcategory(type_name)])
registry_description = reg.get("registry_description", f'{mat_info.Name} (Code: {mat_info.Code})')
# 构建 item
item = LabwareItem(
id=func_name.lower().replace("prcxi_", "")[:8] or func_name[:8],
type=type_name,
function_name=func_name,
docstring=docstring,
size_x=round(_get_size(resource, "size_x"), 4),
size_y=round(_get_size(resource, "size_y"), 4),
size_z=round(_get_size(resource, "size_z"), 4),
model=getattr(resource, "model", None),
category=getattr(resource, "category", type_name),
plate_type=plate_type,
material_info=mat_info,
registry_category=registry_category,
registry_description=registry_description,
include_in_template_matching=func_name in template_func_names,
template_kind=_TEMPLATE_KINDS.get(func_name),
)
# 提取子项信息
children = getattr(resource, "children", None) or []
grid_data = _extract_grid_from_children(resource)
if type_name == "plate" and children:
if grid_data:
item.grid = GridInfo(**grid_data)
c0 = children[0]
well_data = _extract_well_info(c0)
bt = well_data.get("bottom_type")
if bt is not None:
bt = bt.name if hasattr(bt, "name") else str(bt)
else:
bt = "FLAT"
cst = well_data.get("cross_section_type")
if cst is not None:
cst = cst.name if hasattr(cst, "name") else str(cst)
else:
cst = "CIRCLE"
item.well = WellInfo(
size_x=well_data["size_x"],
size_y=well_data["size_y"],
size_z=well_data["size_z"],
max_volume=well_data.get("max_volume"),
bottom_type=bt,
cross_section_type=cst,
material_z_thickness=well_data.get("material_z_thickness"),
)
# volume_functions
vf = ast_data.get("volume_functions")
if vf:
# 需要实际获取 well 尺寸作为 volume_function 参数
item.volume_functions = VolumeFunctions(
type="rectangle",
well_length=well_data["size_x"],
well_width=well_data["size_y"],
)
elif type_name == "tip_rack" and children:
if grid_data:
item.grid = GridInfo(**grid_data)
c0 = children[0]
tip_obj = getattr(c0, "tip", None)
tip_volume = 300.0
tip_length = 60.0
tip_depth = 51.0
tip_filter = False
if tip_obj:
tip_volume = getattr(tip_obj, "maximal_volume", 300.0)
tip_length = getattr(tip_obj, "total_tip_length", 60.0)
tip_depth = getattr(tip_obj, "fitting_depth", 51.0)
tip_filter = getattr(tip_obj, "has_filter", False)
item.tip = TipInfo(
spot_size_x=round(_get_size(c0, "size_x"), 4),
spot_size_y=round(_get_size(c0, "size_y"), 4),
spot_size_z=round(_get_size(c0, "size_z"), 4),
tip_volume=tip_volume,
tip_length=tip_length,
tip_fitting_depth=tip_depth,
has_filter=tip_filter,
)
# 计算 tip_above_rack_length = tip_length - (size_z - dz)
if grid_data:
_dz = grid_data.get("dz", 0.0)
_above = tip_length - (item.size_z - _dz)
item.tip.tip_above_rack_length = round(_above, 4) if _above > 0 else None
elif type_name == "tube_rack" and children:
if grid_data:
item.grid = GridInfo(**grid_data)
c0 = children[0]
item.tube = TubeInfo(
size_x=round(_get_size(c0, "size_x"), 4),
size_y=round(_get_size(c0, "size_y"), 4),
size_z=round(_get_size(c0, "size_z"), 4),
max_volume=getattr(c0, "max_volume", 1500.0) or 1500.0,
)
elif type_name == "plate_adapter":
# 提取 adapter 参数
ahx = getattr(resource, "adapter_hole_size_x", 127.76)
ahy = getattr(resource, "adapter_hole_size_y", 85.48)
ahz = getattr(resource, "adapter_hole_size_z", 10.0)
adx = getattr(resource, "dx", None)
ady = getattr(resource, "dy", None)
adz = getattr(resource, "dz", 0.0)
item.adapter = AdapterInfo(
adapter_hole_size_x=ahx,
adapter_hole_size_y=ahy,
adapter_hole_size_z=ahz,
dx=adx,
dy=ady,
dz=adz,
)
items.append(item)
return LabwareDB(items=items)
def _type_to_yaml_subcategory(type_name: str) -> str:
mapping = {
"plate": "plates",
"tip_rack": "tip_racks",
"trash": "trash",
"tube_rack": "tube_racks",
"plate_adapter": "plate_adapters",
}
return mapping.get(type_name, type_name)
def save_db(db: LabwareDB, path: Optional[Path] = None) -> Path:
"""保存 LabwareDB 到 JSON 文件。"""
out = path or _DB_PATH
with open(out, "w", encoding="utf-8") as f:
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
return out
def load_db(path: Optional[Path] = None) -> LabwareDB:
"""从 JSON 文件加载 LabwareDB。"""
src = path or _DB_PATH
if not src.exists():
return LabwareDB()
with open(src, "r", encoding="utf-8") as f:
return LabwareDB(**json.load(f))
if __name__ == "__main__":
db = import_from_code()
out = save_db(db)
print(f"已导入 {len(db.items)} 个耗材 → {out}")
for item in db.items:
print(f" [{item.type:14s}] {item.function_name}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
"""Pydantic 数据模型,描述所有 PRCXI 耗材类型的 JSON 结构。"""
from __future__ import annotations
import uuid as _uuid
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
class MaterialInfo(BaseModel):
uuid: str = ""
Code: str = ""
Name: str = ""
materialEnum: Optional[int] = None
SupplyType: Optional[int] = None
class GridInfo(BaseModel):
"""孔位网格排列参数"""
num_items_x: int = 12
num_items_y: int = 8
dx: float = 0.0
dy: float = 0.0
dz: float = 0.0
item_dx: float = 9.0
item_dy: float = 9.0
class WellInfo(BaseModel):
"""孔参数 (Plate)"""
size_x: float = 8.0
size_y: float = 8.0
size_z: float = 10.0
max_volume: Optional[float] = None
bottom_type: str = "FLAT" # V / U / FLAT
cross_section_type: str = "CIRCLE" # CIRCLE / RECTANGLE
material_z_thickness: Optional[float] = None
class VolumeFunctions(BaseModel):
"""体积-高度计算函数参数 (矩形 well)"""
type: str = "rectangle"
well_length: float = 0.0
well_width: float = 0.0
class TipInfo(BaseModel):
"""枪头参数 (TipRack)"""
spot_size_x: float = 7.0
spot_size_y: float = 7.0
spot_size_z: float = 0.0
tip_volume: float = 300.0
tip_length: float = 60.0
tip_fitting_depth: float = 51.0
tip_above_rack_length: Optional[float] = None
has_filter: bool = False
class TubeInfo(BaseModel):
"""管参数 (TubeRack)"""
size_x: float = 10.6
size_y: float = 10.6
size_z: float = 40.0
max_volume: float = 1500.0
class AdapterInfo(BaseModel):
"""适配器参数 (PlateAdapter)"""
adapter_hole_size_x: float = 127.76
adapter_hole_size_y: float = 85.48
adapter_hole_size_z: float = 10.0
dx: Optional[float] = None
dy: Optional[float] = None
dz: float = 0.0
LabwareType = Literal["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]
class LabwareItem(BaseModel):
"""一个耗材条目的完整 JSON 表示"""
id: str = Field(default_factory=lambda: _uuid.uuid4().hex[:8])
type: LabwareType = "plate"
function_name: str = ""
docstring: str = ""
# 物理尺寸
size_x: float = 127.0
size_y: float = 85.0
size_z: float = 20.0
model: Optional[str] = None
category: Optional[str] = None
plate_type: Optional[str] = None # non-skirted / semi-skirted / skirted
# 材料信息
material_info: MaterialInfo = Field(default_factory=MaterialInfo)
# Registry 字段
registry_category: List[str] = Field(default_factory=lambda: ["prcxi", "plates"])
registry_description: str = ""
# Plate 特有
grid: Optional[GridInfo] = None
well: Optional[WellInfo] = None
volume_functions: Optional[VolumeFunctions] = None
# TipRack 特有
tip: Optional[TipInfo] = None
# TubeRack 特有
tube: Optional[TubeInfo] = None
# PlateAdapter 特有
adapter: Optional[AdapterInfo] = None
# 模板匹配
include_in_template_matching: bool = False
template_kind: Optional[str] = None
class LabwareDB(BaseModel):
"""整个 labware_db.json 的结构"""
version: str = "1.0"
items: List[LabwareItem] = Field(default_factory=list)

View File

@@ -0,0 +1,292 @@
/**
* form_handler.js — 动态表单逻辑 + 实时预览
*/
// 根据类型显示/隐藏对应的表单段
function onTypeChange() {
const type = document.getElementById('f-type').value;
const sections = {
grid: ['plate', 'tip_rack', 'tube_rack'],
well: ['plate'],
tip: ['tip_rack'],
tube: ['tube_rack'],
adapter: ['plate_adapter'],
};
for (const [sec, types] of Object.entries(sections)) {
const el = document.getElementById('section-' + sec);
if (el) el.style.display = types.includes(type) ? 'block' : 'none';
}
// plate_type 行只对 plate 显示
const ptRow = document.getElementById('row-plate_type');
if (ptRow) ptRow.style.display = type === 'plate' ? 'block' : 'none';
updatePreview();
}
// 从表单收集数据
function collectFormData() {
const g = id => {
const el = document.getElementById(id);
if (!el) return null;
if (el.type === 'checkbox') return el.checked;
if (el.type === 'number') return el.value === '' ? null : parseFloat(el.value);
return el.value || null;
};
const type = g('f-type');
const data = {
type: type,
function_name: g('f-function_name') || 'PRCXI_new',
model: g('f-model'),
docstring: g('f-docstring') || '',
plate_type: type === 'plate' ? g('f-plate_type') : null,
size_x: g('f-size_x') || 127,
size_y: g('f-size_y') || 85,
size_z: g('f-size_z') || 20,
material_info: {
uuid: g('f-mi_uuid') || '',
Code: g('f-mi_code') || '',
Name: g('f-mi_name') || '',
materialEnum: g('f-mi_menum'),
SupplyType: g('f-mi_stype'),
},
registry_category: (g('f-reg_cat') || 'prcxi,plates').split(',').map(s => s.trim()),
registry_description: g('f-reg_desc') || '',
include_in_template_matching: g('f-in_tpl') || false,
template_kind: g('f-tpl_kind') || null,
grid: null,
well: null,
tip: null,
tube: null,
adapter: null,
volume_functions: null,
};
// Grid
if (['plate', 'tip_rack', 'tube_rack'].includes(type)) {
data.grid = {
num_items_x: g('f-grid_nx') || 12,
num_items_y: g('f-grid_ny') || 8,
dx: g('f-grid_dx') || 0,
dy: g('f-grid_dy') || 0,
dz: g('f-grid_dz') || 0,
item_dx: g('f-grid_idx') || 9,
item_dy: g('f-grid_idy') || 9,
};
}
// Well
if (type === 'plate') {
data.well = {
size_x: g('f-well_sx') || 8,
size_y: g('f-well_sy') || 8,
size_z: g('f-well_sz') || 10,
max_volume: g('f-well_vol'),
material_z_thickness: g('f-well_mzt'),
bottom_type: g('f-well_bt') || 'FLAT',
cross_section_type: g('f-well_cs') || 'CIRCLE',
};
if (g('f-has_vf')) {
data.volume_functions = {
type: 'rectangle',
well_length: data.well.size_x,
well_width: data.well.size_y,
};
}
}
// Tip
if (type === 'tip_rack') {
data.tip = {
spot_size_x: g('f-tip_sx') || 7,
spot_size_y: g('f-tip_sy') || 7,
spot_size_z: g('f-tip_sz') || 0,
tip_volume: g('f-tip_vol') || 300,
tip_length: g('f-tip_len') || 60,
tip_fitting_depth: g('f-tip_dep') || 51,
tip_above_rack_length: g('f-tip_above'),
has_filter: g('f-tip_filter') || false,
};
}
// Tube
if (type === 'tube_rack') {
data.tube = {
size_x: g('f-tube_sx') || 10.6,
size_y: g('f-tube_sy') || 10.6,
size_z: g('f-tube_sz') || 40,
max_volume: g('f-tube_vol') || 1500,
};
}
// Adapter
if (type === 'plate_adapter') {
data.adapter = {
adapter_hole_size_x: g('f-adp_hsx') || 127.76,
adapter_hole_size_y: g('f-adp_hsy') || 85.48,
adapter_hole_size_z: g('f-adp_hsz') || 10,
dx: g('f-adp_dx'),
dy: g('f-adp_dy'),
dz: g('f-adp_dz') || 0,
};
}
return data;
}
// 实时预览 (debounce)
let _previewTimer = null;
function updatePreview() {
if (_previewTimer) clearTimeout(_previewTimer);
_previewTimer = setTimeout(() => {
const data = collectFormData();
const topEl = document.getElementById('svg-topdown');
const sideEl = document.getElementById('svg-side');
if (topEl) renderTopDown(topEl, data);
if (sideEl) renderSideProfile(sideEl, data);
}, 200);
}
// 给所有表单元素绑定 input 事件
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('labware-form');
if (!form) return;
form.addEventListener('input', updatePreview);
form.addEventListener('change', updatePreview);
// tip_above_rack_length 与 dz 互算
// 公式: tip_length = tip_above_rack_length + size_z - dz
// 规则: 填 tip_above → 自动算 dz填 dz → 自动算 tip_above
// 改 size_z / tip_length → 优先重算 tip_above若有值否则算 dz
function _getVal(id) {
const el = document.getElementById(id);
return (el && el.value !== '') ? parseFloat(el.value) : null;
}
function _setVal(id, v) {
const el = document.getElementById(id);
if (el) el.value = Math.round(v * 1000) / 1000;
}
function autoCalcTipAbove(changedId) {
const typeEl = document.getElementById('f-type');
if (!typeEl || typeEl.value !== 'tip_rack') return;
const tipLen = _getVal('f-tip_len');
const sizeZ = _getVal('f-size_z');
const dz = _getVal('f-grid_dz');
const above = _getVal('f-tip_above');
// 需要 tip_length 和 size_z 才能计算
if (tipLen == null || sizeZ == null) return;
if (changedId === 'f-tip_above') {
// 用户填了 tip_above → 算 dz
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
} else if (changedId === 'f-grid_dz') {
// 用户填了 dz → 算 tip_above
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
} else {
// size_z 或 tip_length 变了 → 优先重算 tip_above若已有值或 dz 已有值)
if (dz != null) {
_setVal('f-tip_above', tipLen - sizeZ + dz);
} else if (above != null) {
_setVal('f-grid_dz', above + sizeZ - tipLen);
}
}
}
// 绑定 input 事件
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
}
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
const typeEl = document.getElementById('f-type');
if (typeEl && typeEl.value === 'tip_rack') {
autoCalcTipAbove('f-grid_dz');
}
});
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy
function autoCenter() {
const g = id => { const el = document.getElementById(id); return el && el.value !== '' ? parseFloat(el.value) : 0; };
const sizeX = g('f-size_x') || 127;
const sizeY = g('f-size_y') || 85;
const nx = g('f-grid_nx') || 1;
const ny = g('f-grid_ny') || 1;
const itemDx = g('f-grid_idx') || 9;
const itemDy = g('f-grid_idy') || 9;
// 根据当前耗材类型确定子元素尺寸
const type = document.getElementById('f-type').value;
let childSx = 0, childSy = 0;
if (type === 'plate') {
childSx = g('f-well_sx') || 8;
childSy = g('f-well_sy') || 8;
} else if (type === 'tip_rack') {
childSx = g('f-tip_sx') || 7;
childSy = g('f-tip_sy') || 7;
} else if (type === 'tube_rack') {
childSx = g('f-tube_sx') || 10.6;
childSy = g('f-tube_sy') || 10.6;
}
// dx = (板宽 - 孔阵列总占宽) / 2
const dx = (sizeX - (nx - 1) * itemDx - childSx) / 2;
const dy = (sizeY - (ny - 1) * itemDy - childSy) / 2;
const elDx = document.getElementById('f-grid_dx');
const elDy = document.getElementById('f-grid_dy');
if (elDx) elDx.value = Math.round(dx * 100) / 100;
if (elDy) elDy.value = Math.round(dy * 100) / 100;
updatePreview();
}
// 保存
function showMsg(text, ok) {
const el = document.getElementById('status-msg');
if (!el) return;
el.textContent = text;
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 4000);
}
async function saveForm() {
const data = collectFormData();
let url, method;
if (typeof IS_NEW !== 'undefined' && IS_NEW) {
url = '/api/labware';
method = 'POST';
} else {
url = '/api/labware/' + (typeof ITEM_ID !== 'undefined' ? ITEM_ID : '');
method = 'PUT';
}
try {
const r = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const d = await r.json();
if (d.status === 'ok') {
showMsg('保存成功', true);
if (IS_NEW) {
setTimeout(() => location.href = '/labware/' + data.function_name, 500);
}
} else {
showMsg('保存失败: ' + JSON.stringify(d), false);
}
} catch (e) {
showMsg('请求错误: ' + e.message, false);
}
}

View File

@@ -0,0 +1,450 @@
/**
* labware_viz.js — PRCXI 耗材 SVG 2D 可视化渲染引擎
*
* renderTopDown(container, itemData) — 俯视图
* renderSideProfile(container, itemData) — 侧面截面图
*/
const TYPE_COLORS = {
plate: '#3b82f6',
tip_rack: '#10b981',
tube_rack: '#f59e0b',
trash: '#ef4444',
plate_adapter: '#8b5cf6',
};
function _svgNS() { return 'http://www.w3.org/2000/svg'; }
function _makeSVG(w, h) {
const svg = document.createElementNS(_svgNS(), 'svg');
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.setAttribute('width', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.style.background = '#fff';
return svg;
}
function _rect(svg, x, y, w, h, fill, stroke, rx) {
const r = document.createElementNS(_svgNS(), 'rect');
r.setAttribute('x', x); r.setAttribute('y', y);
r.setAttribute('width', w); r.setAttribute('height', h);
r.setAttribute('fill', fill || 'none');
r.setAttribute('stroke', stroke || '#333');
r.setAttribute('stroke-width', '0.5');
if (rx) r.setAttribute('rx', rx);
svg.appendChild(r);
return r;
}
function _circle(svg, cx, cy, r, fill, stroke) {
const c = document.createElementNS(_svgNS(), 'circle');
c.setAttribute('cx', cx); c.setAttribute('cy', cy);
c.setAttribute('r', r);
c.setAttribute('fill', fill || 'none');
c.setAttribute('stroke', stroke || '#333');
c.setAttribute('stroke-width', '0.4');
svg.appendChild(c);
return c;
}
function _text(svg, x, y, txt, size, anchor, fill) {
const t = document.createElementNS(_svgNS(), 'text');
t.setAttribute('x', x); t.setAttribute('y', y);
t.setAttribute('font-size', size || '3');
t.setAttribute('text-anchor', anchor || 'middle');
t.setAttribute('fill', fill || '#666');
t.setAttribute('font-family', 'sans-serif');
t.textContent = txt;
svg.appendChild(t);
return t;
}
function _line(svg, x1, y1, x2, y2, stroke, dash) {
const l = document.createElementNS(_svgNS(), 'line');
l.setAttribute('x1', x1); l.setAttribute('y1', y1);
l.setAttribute('x2', x2); l.setAttribute('y2', y2);
l.setAttribute('stroke', stroke || '#999');
l.setAttribute('stroke-width', '0.3');
if (dash) l.setAttribute('stroke-dasharray', dash);
svg.appendChild(l);
return l;
}
function _title(el, txt) {
const t = document.createElementNS(_svgNS(), 'title');
t.textContent = txt;
el.appendChild(t);
}
// ==================== 俯视图 ====================
function renderTopDown(container, data) {
container.innerHTML = '';
if (!data) return;
const pad = 18;
const sx = data.size_x || 127;
const sy = data.size_y || 85;
const w = sx + pad * 2;
const h = sy + pad * 2;
const svg = _makeSVG(w, h);
const color = TYPE_COLORS[data.type] || '#3b82f6';
const lightColor = color + '22';
// 板子外轮廓
_rect(svg, pad, pad, sx, sy, lightColor, color, 3);
// 尺寸标注
_text(svg, pad + sx / 2, pad - 4, `${sx} mm`, '3.5', 'middle', '#333');
// Y 尺寸 (竖直)
const yt = document.createElementNS(_svgNS(), 'text');
yt.setAttribute('x', pad - 5);
yt.setAttribute('y', pad + sy / 2);
yt.setAttribute('font-size', '3.5');
yt.setAttribute('text-anchor', 'middle');
yt.setAttribute('fill', '#333');
yt.setAttribute('font-family', 'sans-serif');
yt.setAttribute('transform', `rotate(-90, ${pad - 5}, ${pad + sy / 2})`);
yt.textContent = `${sy} mm`;
svg.appendChild(yt);
const grid = data.grid;
const well = data.well;
const tip = data.tip;
const tube = data.tube;
if (grid && (well || tip || tube)) {
const nx = grid.num_items_x || 1;
const ny = grid.num_items_y || 1;
const dx = grid.dx || 0;
const dy = grid.dy || 0;
const idx = grid.item_dx || 9;
const idy = grid.item_dy || 9;
const child = well || tip || tube;
const csx = child.size_x || child.spot_size_x || 8;
const csy = child.size_y || child.spot_size_y || 8;
const isCircle = well ? (well.cross_section_type === 'CIRCLE') : (!!tip);
// 行列标签
for (let col = 0; col < nx; col++) {
const cx = pad + dx + csx / 2 + col * idx;
_text(svg, cx, pad + sy + 5, String(col + 1), '2.5', 'middle', '#999');
}
const rowLabels = 'ABCDEFGHIJKLMNOP';
for (let row = 0; row < ny; row++) {
const cy = pad + dy + csy / 2 + row * idy;
_text(svg, pad - 4, cy + 1, rowLabels[row] || String(row), '2.5', 'middle', '#999');
}
// 绘制孔位
for (let col = 0; col < nx; col++) {
for (let row = 0; row < ny; row++) {
const cx = pad + dx + csx / 2 + col * idx;
const cy = pad + dy + csy / 2 + row * idy;
let el;
if (isCircle) {
const r = Math.min(csx, csy) / 2;
el = _circle(svg, cx, cy, r, '#fff', color);
} else {
el = _rect(svg, cx - csx / 2, cy - csy / 2, csx, csy, '#fff', color);
}
const label = (rowLabels[row] || '') + String(col + 1);
_title(el, `${label}: ${csx}x${csy} mm`);
// hover 效果
el.style.cursor = 'pointer';
el.addEventListener('mouseenter', () => el.setAttribute('fill', color + '44'));
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
}
}
// dx / dy 标注(板边到首个子元素左上角)
const dimColor = '#e67e22';
const firstLeft = pad + dx; // 首列子元素左边 X
const firstTop = pad + dy; // 首行子元素上边 Y
if (dx > 0.1) {
// dx: 板左边 → 首列子元素左边,画在第一行子元素中心高度
const annY = firstTop + csy / 2;
_line(svg, pad, annY, firstLeft, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, firstLeft, annY - 2, firstLeft, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
if (dy > 0.1) {
// dy: 板上边 → 首行子元素上边,画在第一列子元素中心宽度
const annX = firstLeft + csx / 2;
_line(svg, annX, pad, annX, firstTop, dimColor, '1,1');
_line(svg, annX - 2, pad, annX + 2, pad, dimColor);
_line(svg, annX - 2, firstTop, annX + 2, firstTop, dimColor);
_text(svg, annX + 4, pad + dy / 2 + 1, `dy=${dy}`, '2.5', 'start', dimColor);
}
} else if (data.type === 'plate_adapter' && data.adapter) {
// 绘制适配器凹槽
const adp = data.adapter;
const ahx = adp.adapter_hole_size_x || 127;
const ahy = adp.adapter_hole_size_y || 85;
const adx = adp.dx != null ? adp.dx : (sx - ahx) / 2;
const ady = adp.dy != null ? adp.dy : (sy - ahy) / 2;
_rect(svg, pad + adx, pad + ady, ahx, ahy, '#f0f0ff', '#8b5cf6', 2);
_text(svg, pad + adx + ahx / 2, pad + ady + ahy / 2, `${ahx}x${ahy}`, '4', 'middle', '#8b5cf6');
} else if (data.type === 'trash') {
// 简单标记
_text(svg, pad + sx / 2, pad + sy / 2, 'TRASH', '8', 'middle', '#ef4444');
}
container.appendChild(svg);
_enableZoomPan(svg, `0 0 ${w} ${h}`);
}
// ==================== 侧面截面图 ====================
function renderSideProfile(container, data) {
container.innerHTML = '';
if (!data) return;
const pad = 20;
const sx = data.size_x || 127;
const sz = data.size_z || 20;
// 按比例缩放,侧面以 X-Z 面
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
// 计算枪头露出高度(仅 tip_rack
const tip = data.tip;
const grid = data.grid;
let tipAbove = 0;
if (data.type === 'tip_rack' && tip) {
if (tip.tip_above_rack_length != null && tip.tip_above_rack_length > 0) {
tipAbove = tip.tip_above_rack_length;
} else if (tip.tip_length && grid) {
const dz = grid.dz || 0;
const calc = tip.tip_length - (sz - dz);
if (calc > 0) tipAbove = calc;
}
}
const drawW = sx;
const drawH = sz;
const w = drawW + pad * 2 + 30; // 额外空间给标注
const h = drawH + tipAbove + pad * 2 + 10;
const svg = _makeSVG(w, h);
const color = TYPE_COLORS[data.type] || '#3b82f6';
const baseY = pad + tipAbove + drawH; // 底部 Y
const rackTopY = pad + tipAbove; // rack 顶部 Y
// 板壳矩形
_rect(svg, pad, rackTopY, drawW, drawH, color + '15', color);
// 尺寸标注
// X 方向
_line(svg, pad, baseY + 5, pad + drawW, baseY + 5, '#333');
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
// Z 方向
_line(svg, pad + drawW + 5, rackTopY, pad + drawW + 5, baseY, '#333');
const zt = document.createElementNS(_svgNS(), 'text');
zt.setAttribute('x', pad + drawW + 12);
zt.setAttribute('y', rackTopY + drawH / 2);
zt.setAttribute('font-size', '3.5');
zt.setAttribute('text-anchor', 'middle');
zt.setAttribute('fill', '#333');
zt.setAttribute('font-family', 'sans-serif');
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${rackTopY + drawH / 2})`);
zt.textContent = `${sz} mm`;
svg.appendChild(zt);
const well = data.well;
const tube = data.tube;
if (grid && (well || tip || tube)) {
const dx = grid.dx || 0;
const dz = grid.dz || 0;
const idx = grid.item_dx || 9;
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
const dimColor = '#e67e22';
const child = well || tube;
const childTip = tip;
if (child) {
const csx = child.size_x || 8;
const csz = child.size_z || 10;
const bt = well ? (well.bottom_type || 'FLAT') : 'FLAT';
// 画几个代表性的孔截面
const nDraw = Math.min(nx, 12);
for (let i = 0; i < nDraw; i++) {
const cx = pad + dx + csx / 2 + i * idx;
const topZ = baseY - dz - csz;
const botZ = baseY - dz;
// 孔壁
_rect(svg, cx - csx / 2, topZ, csx, csz, '#e0e8ff', color, 0.5);
// 底部形状
if (bt === 'V') {
// V 底 三角
const triH = Math.min(csx / 2, csz * 0.3);
const p = document.createElementNS(_svgNS(), 'polygon');
p.setAttribute('points',
`${cx - csx / 2},${botZ - triH} ${cx},${botZ} ${cx + csx / 2},${botZ - triH}`);
p.setAttribute('fill', color + '33');
p.setAttribute('stroke', color);
p.setAttribute('stroke-width', '0.3');
svg.appendChild(p);
} else if (bt === 'U') {
// U 底 圆弧
const arcR = csx / 2;
const p = document.createElementNS(_svgNS(), 'path');
p.setAttribute('d', `M ${cx - csx / 2} ${botZ - arcR} A ${arcR} ${arcR} 0 0 0 ${cx + csx / 2} ${botZ - arcR}`);
p.setAttribute('fill', color + '33');
p.setAttribute('stroke', color);
p.setAttribute('stroke-width', '0.3');
svg.appendChild(p);
}
}
// dz 标注
if (dz > 0) {
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
}
// dx 标注
if (dx > 0.1) {
const annY = rackTopY + 4;
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
}
if (childTip) {
// 枪头截面
const tipLen = childTip.tip_length || 50;
const nDraw = Math.min(nx, 12);
for (let i = 0; i < nDraw; i++) {
const cx = pad + dx + 3.5 + i * idx;
// 枪头顶部 = rack顶部 - 露出长度
const tipTopZ = rackTopY - tipAbove;
const drawLen = Math.min(tipLen, sz - dz + tipAbove);
// 枪头轮廓 (梯形)
const topW = 4;
const botW = 1.5;
const p = document.createElementNS(_svgNS(), 'polygon');
p.setAttribute('points',
`${cx - topW / 2},${tipTopZ} ${cx + topW / 2},${tipTopZ} ${cx + botW / 2},${tipTopZ + drawLen} ${cx - botW / 2},${tipTopZ + drawLen}`);
p.setAttribute('fill', '#10b98133');
p.setAttribute('stroke', '#10b981');
p.setAttribute('stroke-width', '0.3');
svg.appendChild(p);
}
// dz 标注
if (dz > 0) {
const lx = pad + dx + nDraw * idx + 5;
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
}
// dx 标注
if (dx > 0.1) {
const annY = rackTopY + 4;
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
// 露出长度标注线
if (tipAbove > 0) {
const annotX = pad + dx + nDraw * idx + 8;
// rack 顶部水平参考线
_line(svg, annotX - 3, rackTopY, annotX + 3, rackTopY, '#10b981');
// 枪头顶部水平参考线
_line(svg, annotX - 3, rackTopY - tipAbove, annotX + 3, rackTopY - tipAbove, '#10b981');
// 竖直标注线
_line(svg, annotX, rackTopY - tipAbove, annotX, rackTopY, '#10b981', '1,1');
_text(svg, annotX + 2, rackTopY - tipAbove / 2 + 1, `露出=${Math.round(tipAbove * 100) / 100}mm`, '2.5', 'start', '#10b981');
}
}
} else if (data.type === 'plate_adapter' && data.adapter) {
const adp = data.adapter;
const ahz = adp.adapter_hole_size_z || 10;
const adz = adp.dz || 0;
const adx_val = adp.dx != null ? adp.dx : (sx - (adp.adapter_hole_size_x || 127)) / 2;
const ahx = adp.adapter_hole_size_x || 127;
// 凹槽截面
_rect(svg, pad + adx_val, rackTopY + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
_text(svg, pad + adx_val + ahx / 2, rackTopY + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
} else if (data.type === 'trash') {
_text(svg, pad + drawW / 2, rackTopY + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
}
container.appendChild(svg);
_enableZoomPan(svg, `0 0 ${w} ${h}`);
}
// ==================== 缩放 & 平移 ====================
function _enableZoomPan(svgEl, origViewBox) {
const parts = origViewBox.split(' ').map(Number);
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
const origVx = vx, origVy = vy, origW = vw, origH = vh;
const MIN_SCALE = 0.5, MAX_SCALE = 5;
function applyViewBox() {
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
}
function resetView() {
vx = origVx; vy = origVy; vw = origW; vh = origH;
applyViewBox();
}
// 将 resetView 挂到 svg 元素上,方便外部调用
svgEl._resetView = resetView;
svgEl.addEventListener('wheel', function (e) {
e.preventDefault();
if (e.ctrlKey) {
// pinch / ctrl+scroll → 缩放
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
const newW = vw * factor;
const newH = vh * factor;
// 限制缩放范围
if (newW < origW / MAX_SCALE || newW > origW * (1 / MIN_SCALE)) return;
// 以鼠标位置为缩放中心
const rect = svgEl.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
vx += (vw - newW) * mx;
vy += (vh - newH) * my;
vw = newW;
vh = newH;
} else {
// 普通滚轮 → 平移
const panSpeed = vw * 0.002;
vx += e.deltaX * panSpeed;
vy += e.deltaY * panSpeed;
}
applyViewBox();
}, { passive: false });
}
// 回中按钮:重置指定容器内 SVG 的 viewBox
function resetSvgView(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const svg = container.querySelector('svg');
if (svg && svg._resetView) svg._resetView();
}

View File

@@ -0,0 +1,295 @@
/* PRCXI 耗材管理 - 全局样式 */
:root {
--c-primary: #3b82f6;
--c-primary-dark: #2563eb;
--c-danger: #ef4444;
--c-warning: #f59e0b;
--c-success: #10b981;
--c-gray-50: #f9fafb;
--c-gray-100: #f3f4f6;
--c-gray-200: #e5e7eb;
--c-gray-300: #d1d5db;
--c-gray-500: #6b7280;
--c-gray-700: #374151;
--c-gray-900: #111827;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--c-gray-50);
color: var(--c-gray-900);
line-height: 1.5;
}
/* 顶部导航 */
.topbar {
background: #fff;
border-bottom: 1px solid var(--c-gray-200);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar .logo {
font-weight: 700;
font-size: 1.1rem;
color: var(--c-gray-900);
text-decoration: none;
}
/* 容器 */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* 页头 */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 12px;
}
.page-header h1 { font-size: 1.5rem; }
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
transition: all 0.15s;
white-space: nowrap;
}
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
.btn-primary { background: var(--c-primary); color: #fff; }
.btn-primary:hover { background: var(--c-primary-dark); }
.btn-outline { background: #fff; color: var(--c-gray-700); border-color: var(--c-gray-300); }
.btn-outline:hover { background: var(--c-gray-100); }
.btn-danger { background: var(--c-danger); color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-warning { background: var(--c-warning); color: #fff; }
.btn-warning:hover { background: #d97706; }
/* 徽章 */
.badge {
background: var(--c-gray-200);
color: var(--c-gray-700);
font-size: 0.8rem;
padding: 2px 8px;
border-radius: 12px;
font-weight: 500;
margin-left: 4px;
}
/* 状态消息 */
.status-msg {
padding: 12px 16px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 0.9rem;
}
.msg-ok { background: #d1fae5; color: #065f46; }
.msg-err { background: #fee2e2; color: #991b1b; }
/* 类型分段 */
.type-section { margin-bottom: 32px; }
.type-section h2 {
font-size: 1.1rem;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.type-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
/* 卡片网格 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
/* 耗材卡片 */
.labware-card {
background: #fff;
border: 1px solid var(--c-gray-200);
border-radius: var(--radius);
padding: 16px;
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s;
}
.labware-card:hover {
border-color: var(--c-primary);
box-shadow: 0 4px 12px rgba(59,130,246,0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--c-gray-900);
word-break: break-all;
}
.card-body { font-size: 0.85rem; color: var(--c-gray-500); }
.card-info { margin-bottom: 2px; }
.card-info .label { color: var(--c-gray-700); font-weight: 500; }
.card-footer {
margin-top: 12px;
display: flex;
gap: 8px;
border-top: 1px solid var(--c-gray-100);
padding-top: 10px;
}
/* 标签 */
.tag {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
}
.tag-tpl { background: #dbeafe; color: #1e40af; }
.tag-plate { background: #dbeafe; color: #1e40af; }
.tag-tip_rack { background: #d1fae5; color: #065f46; }
.tag-trash { background: #fee2e2; color: #991b1b; }
.tag-tube_rack { background: #fef3c7; color: #92400e; }
.tag-plate_adapter { background: #ede9fe; color: #5b21b6; }
/* 详情页布局 */
.detail-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 900px) {
.detail-layout { grid-template-columns: 1fr; }
}
.detail-info, .detail-viz { display: flex; flex-direction: column; gap: 16px; }
.info-card, .viz-card {
background: #fff;
border: 1px solid var(--c-gray-200);
border-radius: var(--radius);
padding: 16px;
}
.info-card h3, .viz-card h3 {
font-size: 0.95rem;
margin-bottom: 12px;
color: var(--c-gray-700);
border-bottom: 1px solid var(--c-gray-100);
padding-bottom: 8px;
}
.info-table { width: 100%; font-size: 0.85rem; }
.info-table td { padding: 4px 8px; border-bottom: 1px solid var(--c-gray-100); }
.info-table .label { color: var(--c-gray-500); font-weight: 500; width: 140px; }
.info-table code { background: var(--c-gray-100); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
.info-table code.small { font-size: 0.7rem; }
/* SVG 容器 */
#svg-topdown, #svg-side {
width: 100%;
min-height: 200px;
overflow: hidden;
}
#svg-topdown svg, #svg-side svg {
display: block;
width: 100%;
height: auto;
touch-action: none;
}
/* 编辑页布局 */
.edit-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 900px) {
.edit-layout { grid-template-columns: 1fr; }
}
.edit-form { display: flex; flex-direction: column; gap: 16px; }
.edit-preview { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; align-self: start; }
/* 表单 */
.form-section {
background: #fff;
border: 1px solid var(--c-gray-200);
border-radius: var(--radius);
padding: 16px;
}
.form-section h3 {
font-size: 0.95rem;
margin-bottom: 12px;
color: var(--c-gray-700);
}
.form-row { margin-bottom: 10px; }
.form-row label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
.form-row input, .form-row select, .form-row textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--c-gray-300);
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
}
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.form-row-2, .form-row-3 { display: grid; gap: 12px; margin-bottom: 10px; }
.form-row-2 { grid-template-columns: 1fr 1fr; }
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-row-2 label, .form-row-3 label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
.form-row-2 input, .form-row-2 select,
.form-row-3 input, .form-row-3 select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--c-gray-300);
border-radius: 6px;
font-size: 0.85rem;
}
.form-row-2 input:focus, .form-row-3 input:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
/* 双语标签中文部分 */
.label-cn { color: var(--c-gray-400, #9ca3af); font-weight: 400; margin-left: 4px; }

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PRCXI 耗材管理{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
{% block head_extra %}{% endblock %}
</head>
<body>
<nav class="topbar">
<a href="/" class="logo">PRCXI 耗材管理</a>
<div class="nav-actions">
<a href="/labware/new" class="btn btn-primary btn-sm">+ 新建耗材</a>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}{{ item.function_name }} - PRCXI{% endblock %}
{% block content %}
<div class="page-header">
<h1>{{ item.function_name }}</h1>
<div class="header-actions">
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-primary">编辑</a>
<a href="/" class="btn btn-outline">返回列表</a>
</div>
</div>
<div class="detail-layout">
<!-- 左侧: 信息 -->
<div class="detail-info">
<div class="info-card">
<h3>基本信息</h3>
<table class="info-table">
<tr><td class="label">类型</td><td><span class="tag tag-{{ item.type }}">{{ item.type }}</span></td></tr>
<tr><td class="label">函数名</td><td><code>{{ item.function_name }}</code></td></tr>
<tr><td class="label">Model</td><td>{{ item.model or '-' }}</td></tr>
{% if item.plate_type %}
<tr><td class="label">Plate Type</td><td>{{ item.plate_type }}</td></tr>
{% endif %}
<tr><td class="label">Docstring</td><td>{{ item.docstring or '-' }}</td></tr>
</table>
</div>
<div class="info-card">
<h3>物理尺寸 (mm)</h3>
<table class="info-table">
<tr><td class="label">X</td><td>{{ item.size_x }}</td></tr>
<tr><td class="label">Y</td><td>{{ item.size_y }}</td></tr>
<tr><td class="label">Z</td><td>{{ item.size_z }}</td></tr>
</table>
</div>
<div class="info-card">
<h3>材料信息</h3>
<table class="info-table">
<tr><td class="label">UUID</td><td><code class="small">{{ item.material_info.uuid }}</code></td></tr>
<tr><td class="label">Code</td><td>{{ item.material_info.Code }}</td></tr>
<tr><td class="label">Name</td><td>{{ item.material_info.Name }}</td></tr>
{% if item.material_info.materialEnum is not none %}
<tr><td class="label">materialEnum</td><td>{{ item.material_info.materialEnum }}</td></tr>
{% endif %}
{% if item.material_info.SupplyType is not none %}
<tr><td class="label">SupplyType</td><td>{{ item.material_info.SupplyType }}</td></tr>
{% endif %}
</table>
</div>
{% if item.grid %}
<div class="info-card">
<h3>网格排列</h3>
<table class="info-table">
<tr><td class="label">列 x 行</td><td>{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}</td></tr>
<tr><td class="label">dx, dy, dz</td><td>{{ item.grid.dx }}, {{ item.grid.dy }}, {{ item.grid.dz }}</td></tr>
<tr><td class="label">item_dx, item_dy</td><td>{{ item.grid.item_dx }}, {{ item.grid.item_dy }}</td></tr>
</table>
</div>
{% endif %}
{% if item.well %}
<div class="info-card">
<h3>孔参数 (Well)</h3>
<table class="info-table">
<tr><td class="label">尺寸</td><td>{{ item.well.size_x }} x {{ item.well.size_y }} x {{ item.well.size_z }}</td></tr>
{% if item.well.max_volume is not none %}
<tr><td class="label">最大体积</td><td>{{ item.well.max_volume }} uL</td></tr>
{% endif %}
<tr><td class="label">底部类型</td><td>{{ item.well.bottom_type }}</td></tr>
<tr><td class="label">截面类型</td><td>{{ item.well.cross_section_type }}</td></tr>
{% if item.well.material_z_thickness is not none %}
<tr><td class="label">材料Z厚度</td><td>{{ item.well.material_z_thickness }}</td></tr>
{% endif %}
</table>
</div>
{% endif %}
{% if item.tip %}
<div class="info-card">
<h3>枪头参数 (Tip)</h3>
<table class="info-table">
<tr><td class="label">Spot 尺寸</td><td>{{ item.tip.spot_size_x }} x {{ item.tip.spot_size_y }} x {{ item.tip.spot_size_z }}</td></tr>
<tr><td class="label">容量</td><td>{{ item.tip.tip_volume }} uL</td></tr>
<tr><td class="label">长度</td><td>{{ item.tip.tip_length }} mm</td></tr>
<tr><td class="label">取枪头插入深度</td><td>{{ item.tip.tip_fitting_depth }} mm</td></tr>
{% if item.tip.tip_above_rack_length is not none %}
<tr><td class="label">枪头露出枪头盒长度</td><td>{{ item.tip.tip_above_rack_length }} mm</td></tr>
{% endif %}
<tr><td class="label">有滤芯</td><td>{{ item.tip.has_filter }}</td></tr>
</table>
</div>
{% endif %}
{% if item.tube %}
<div class="info-card">
<h3>管参数 (Tube)</h3>
<table class="info-table">
<tr><td class="label">尺寸</td><td>{{ item.tube.size_x }} x {{ item.tube.size_y }} x {{ item.tube.size_z }}</td></tr>
<tr><td class="label">最大体积</td><td>{{ item.tube.max_volume }} uL</td></tr>
</table>
</div>
{% endif %}
{% if item.adapter %}
<div class="info-card">
<h3>适配器参数</h3>
<table class="info-table">
<tr><td class="label">Hole 尺寸</td><td>{{ item.adapter.adapter_hole_size_x }} x {{ item.adapter.adapter_hole_size_y }} x {{ item.adapter.adapter_hole_size_z }}</td></tr>
<tr><td class="label">dx, dy, dz</td><td>{{ item.adapter.dx }}, {{ item.adapter.dy }}, {{ item.adapter.dz }}</td></tr>
</table>
</div>
{% endif %}
<div class="info-card">
<h3>Registry</h3>
<table class="info-table">
<tr><td class="label">分类</td><td>{{ item.registry_category | join(' / ') }}</td></tr>
<tr><td class="label">描述</td><td>{{ item.registry_description }}</td></tr>
<tr><td class="label">模板匹配</td><td>{{ item.include_in_template_matching }}</td></tr>
{% if item.template_kind %}
<tr><td class="label">模板类型</td><td>{{ item.template_kind }}</td></tr>
{% endif %}
</table>
</div>
</div>
<!-- 右侧: 可视化 -->
<div class="detail-viz">
<div class="viz-card">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
俯视图 (Top-Down)
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
</h3>
<div id="svg-topdown"></div>
</div>
<div class="viz-card">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
侧面截面图 (Side Profile)
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
</h3>
<div id="svg-side"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/labware_viz.js"></script>
<script>
const itemData = {{ item.model_dump() | tojson }};
document.addEventListener('DOMContentLoaded', () => {
renderTopDown(document.getElementById('svg-topdown'), itemData);
renderSideProfile(document.getElementById('svg-side'), itemData);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,257 @@
{% extends "base.html" %}
{% block title %}{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %} - PRCXI{% endblock %}
{% block content %}
<div class="page-header">
<h1>{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %}</h1>
<div class="header-actions">
<a href="/" class="btn btn-outline">返回列表</a>
</div>
</div>
<div id="status-msg" class="status-msg" style="display:none;"></div>
<div class="edit-layout">
<!-- 左侧: 表单 -->
<div class="edit-form">
<form id="labware-form" onsubmit="return false;">
<!-- 基本信息 -->
<div class="form-section">
<h3>基本信息</h3>
<div class="form-row">
<label>类型</label>
<select name="type" id="f-type" onchange="onTypeChange()">
<option value="plate" {% if labware_type == 'plate' %}selected{% endif %}>Plate (孔板)</option>
<option value="tip_rack" {% if labware_type == 'tip_rack' %}selected{% endif %}>TipRack (吸头盒)</option>
<option value="trash" {% if labware_type == 'trash' %}selected{% endif %}>Trash (废弃槽)</option>
<option value="tube_rack" {% if labware_type == 'tube_rack' %}selected{% endif %}>TubeRack (管架)</option>
<option value="plate_adapter" {% if labware_type == 'plate_adapter' %}selected{% endif %}>PlateAdapter (适配器)</option>
</select>
</div>
<div class="form-row">
<label>函数名</label>
<input type="text" name="function_name" id="f-function_name"
value="{{ item.function_name if item else 'PRCXI_new_labware' }}"
placeholder="PRCXI_xxx">
</div>
<div class="form-row">
<label>Model</label>
<input type="text" name="model" id="f-model"
value="{{ item.model if item and item.model else '' }}">
</div>
<div class="form-row">
<label>Docstring</label>
<textarea name="docstring" id="f-docstring" rows="2">{{ item.docstring if item else '' }}</textarea>
</div>
<div class="form-row" id="row-plate_type" style="display:none;">
<label>Plate Type</label>
<select name="plate_type" id="f-plate_type">
<option value="">-</option>
<option value="skirted" {% if item and item.plate_type == 'skirted' %}selected{% endif %}>skirted</option>
<option value="semi-skirted" {% if item and item.plate_type == 'semi-skirted' %}selected{% endif %}>semi-skirted</option>
<option value="non-skirted" {% if item and item.plate_type == 'non-skirted' %}selected{% endif %}>non-skirted</option>
</select>
</div>
</div>
<!-- 物理尺寸 -->
<div class="form-section">
<h3>物理尺寸 Physical Dimensions (mm)</h3>
<div class="form-row-3">
<div><label>size_x <span class="label-cn">板长</span></label><input type="number" step="any" name="size_x" id="f-size_x" value="{{ item.size_x if item else 127 }}"></div>
<div><label>size_y <span class="label-cn">板宽</span></label><input type="number" step="any" name="size_y" id="f-size_y" value="{{ item.size_y if item else 85 }}"></div>
<div><label>size_z <span class="label-cn">板高</span></label><input type="number" step="any" name="size_z" id="f-size_z" value="{{ item.size_z if item else 20 }}"></div>
</div>
</div>
<!-- 材料信息 -->
<div class="form-section">
<h3>材料信息</h3>
<div class="form-row">
<label>UUID</label>
<input type="text" name="mi_uuid" id="f-mi_uuid"
value="{{ item.material_info.uuid if item else '' }}">
</div>
<div class="form-row-2">
<div><label>Code</label><input type="text" name="mi_code" id="f-mi_code" value="{{ item.material_info.Code if item else '' }}"></div>
<div><label>Name</label><input type="text" name="mi_name" id="f-mi_name" value="{{ item.material_info.Name if item else '' }}"></div>
</div>
<div class="form-row-2">
<div><label>materialEnum</label><input type="number" name="mi_menum" id="f-mi_menum" value="{{ item.material_info.materialEnum if item and item.material_info.materialEnum is not none else '' }}"></div>
<div><label>SupplyType</label><input type="number" name="mi_stype" id="f-mi_stype" value="{{ item.material_info.SupplyType if item and item.material_info.SupplyType is not none else '' }}"></div>
</div>
</div>
<!-- 网格排列 (plate/tip_rack/tube_rack) -->
<div class="form-section" id="section-grid" style="display:none;">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
网格排列 Grid Layout
<button type="button" class="btn btn-sm btn-outline" onclick="autoCenter()">自动居中 Auto-Center</button>
</h3>
<div class="form-row-2">
<div><label>num_items_x <span class="label-cn">列数</span></label><input type="number" name="grid_nx" id="f-grid_nx" value="{{ item.grid.num_items_x if item and item.grid else 12 }}"></div>
<div><label>num_items_y <span class="label-cn">行数</span></label><input type="number" name="grid_ny" id="f-grid_ny" value="{{ item.grid.num_items_y if item and item.grid else 8 }}"></div>
</div>
<div class="form-row-3">
<div><label>dx <span class="label-cn">首孔X偏移</span></label><input type="number" step="any" name="grid_dx" id="f-grid_dx" value="{{ item.grid.dx if item and item.grid else 0 }}"></div>
<div><label>dy <span class="label-cn">首孔Y偏移</span></label><input type="number" step="any" name="grid_dy" id="f-grid_dy" value="{{ item.grid.dy if item and item.grid else 0 }}"></div>
<div><label>dz <span class="label-cn">孔底Z偏移</span></label><input type="number" step="any" name="grid_dz" id="f-grid_dz" value="{{ item.grid.dz if item and item.grid else 0 }}"></div>
</div>
<div class="form-row-2">
<div><label>item_dx <span class="label-cn">列间距</span></label><input type="number" step="any" name="grid_idx" id="f-grid_idx" value="{{ item.grid.item_dx if item and item.grid else 9 }}"></div>
<div><label>item_dy <span class="label-cn">行间距</span></label><input type="number" step="any" name="grid_idy" id="f-grid_idy" value="{{ item.grid.item_dy if item and item.grid else 9 }}"></div>
</div>
</div>
<!-- Well 参数 (plate) -->
<div class="form-section" id="section-well" style="display:none;">
<h3>孔参数 Well</h3>
<div class="form-row-3">
<div><label>size_x <span class="label-cn">孔长</span></label><input type="number" step="any" name="well_sx" id="f-well_sx" value="{{ item.well.size_x if item and item.well else 8 }}"></div>
<div><label>size_y <span class="label-cn">孔宽</span></label><input type="number" step="any" name="well_sy" id="f-well_sy" value="{{ item.well.size_y if item and item.well else 8 }}"></div>
<div><label>size_z <span class="label-cn">孔深</span></label><input type="number" step="any" name="well_sz" id="f-well_sz" value="{{ item.well.size_z if item and item.well else 10 }}"></div>
</div>
<div class="form-row-2">
<div><label>max_volume <span class="label-cn">最大容量 (uL)</span></label><input type="number" step="any" name="well_vol" id="f-well_vol" value="{{ item.well.max_volume if item and item.well and item.well.max_volume is not none else '' }}"></div>
<div><label>material_z_thickness <span class="label-cn">底壁厚度</span></label><input type="number" step="any" name="well_mzt" id="f-well_mzt" value="{{ item.well.material_z_thickness if item and item.well and item.well.material_z_thickness is not none else '' }}"></div>
</div>
<div class="form-row-2">
<div>
<label>bottom_type <span class="label-cn">底部形状</span></label>
<select name="well_bt" id="f-well_bt">
<option value="FLAT" {% if item and item.well and item.well.bottom_type == 'FLAT' %}selected{% endif %}>FLAT</option>
<option value="V" {% if item and item.well and item.well.bottom_type == 'V' %}selected{% endif %}>V</option>
<option value="U" {% if item and item.well and item.well.bottom_type == 'U' %}selected{% endif %}>U</option>
</select>
</div>
<div>
<label>cross_section_type <span class="label-cn">截面形状</span></label>
<select name="well_cs" id="f-well_cs">
<option value="CIRCLE" {% if item and item.well and item.well.cross_section_type == 'CIRCLE' %}selected{% endif %}>CIRCLE</option>
<option value="RECTANGLE" {% if item and item.well and item.well.cross_section_type == 'RECTANGLE' %}selected{% endif %}>RECTANGLE</option>
</select>
</div>
</div>
<div class="form-row">
<label><input type="checkbox" name="has_vf" id="f-has_vf" {% if item and item.volume_functions %}checked{% endif %}> 使用 volume_functions (rectangle)</label>
</div>
</div>
<!-- Tip 参数 (tip_rack) -->
<div class="form-section" id="section-tip" style="display:none;">
<h3>枪头参数 Tip</h3>
<div class="form-row-3">
<div><label>spot_size_x <span class="label-cn">卡槽长</span></label><input type="number" step="any" name="tip_sx" id="f-tip_sx" value="{{ item.tip.spot_size_x if item and item.tip else 7 }}"></div>
<div><label>spot_size_y <span class="label-cn">卡槽宽</span></label><input type="number" step="any" name="tip_sy" id="f-tip_sy" value="{{ item.tip.spot_size_y if item and item.tip else 7 }}"></div>
<div><label>spot_size_z <span class="label-cn">卡槽高</span></label><input type="number" step="any" name="tip_sz" id="f-tip_sz" value="{{ item.tip.spot_size_z if item and item.tip else 0 }}"></div>
</div>
<div class="form-row-3">
<div><label>tip_volume <span class="label-cn">枪头容量 (uL)</span></label><input type="number" step="any" name="tip_vol" id="f-tip_vol" value="{{ item.tip.tip_volume if item and item.tip else 300 }}"></div>
<div><label>tip_length <span class="label-cn">枪头总长度 (mm)</span></label><input type="number" step="any" name="tip_len" id="f-tip_len" value="{{ item.tip.tip_length if item and item.tip else 60 }}"></div>
<div><label>fitting_depth <span class="label-cn">取枪头时插入的长度 (mm)</span></label><input type="number" step="any" name="tip_dep" id="f-tip_dep" value="{{ item.tip.tip_fitting_depth if item and item.tip else 51 }}"></div>
</div>
<div class="form-row">
<label>tip_above_rack_length <span class="label-cn">枪头在枪头盒上方的部分的长度 (mm)</span></label>
<input type="number" step="any" name="tip_above" id="f-tip_above"
value="{{ item.tip.tip_above_rack_length if item and item.tip and item.tip.tip_above_rack_length is not none else '' }}"
placeholder="tip_length - (size_z - dz)">
<small style="color:#888;margin-top:2px;">公式: tip_length = tip_above + size_z - dz填 tip_above 自动算 dz填 dz 自动算 tip_above</small>
</div>
<div class="form-row">
<label><input type="checkbox" name="tip_filter" id="f-tip_filter" {% if item and item.tip and item.tip.has_filter %}checked{% endif %}> has_filter</label>
</div>
</div>
<!-- Tube 参数 (tube_rack) -->
<div class="form-section" id="section-tube" style="display:none;">
<h3>管参数 Tube</h3>
<div class="form-row-3">
<div><label>size_x <span class="label-cn">管径X</span></label><input type="number" step="any" name="tube_sx" id="f-tube_sx" value="{{ item.tube.size_x if item and item.tube else 10.6 }}"></div>
<div><label>size_y <span class="label-cn">管径Y</span></label><input type="number" step="any" name="tube_sy" id="f-tube_sy" value="{{ item.tube.size_y if item and item.tube else 10.6 }}"></div>
<div><label>size_z <span class="label-cn">管高</span></label><input type="number" step="any" name="tube_sz" id="f-tube_sz" value="{{ item.tube.size_z if item and item.tube else 40 }}"></div>
</div>
<div class="form-row">
<label>max_volume <span class="label-cn">最大容量 (uL)</span></label>
<input type="number" step="any" name="tube_vol" id="f-tube_vol" value="{{ item.tube.max_volume if item and item.tube else 1500 }}">
</div>
</div>
<!-- Adapter 参数 (plate_adapter) -->
<div class="form-section" id="section-adapter" style="display:none;">
<h3>适配器参数 Adapter</h3>
<div class="form-row-3">
<div><label>hole_size_x <span class="label-cn">凹槽长</span></label><input type="number" step="any" name="adp_hsx" id="f-adp_hsx" value="{{ item.adapter.adapter_hole_size_x if item and item.adapter else 127.76 }}"></div>
<div><label>hole_size_y <span class="label-cn">凹槽宽</span></label><input type="number" step="any" name="adp_hsy" id="f-adp_hsy" value="{{ item.adapter.adapter_hole_size_y if item and item.adapter else 85.48 }}"></div>
<div><label>hole_size_z <span class="label-cn">凹槽深</span></label><input type="number" step="any" name="adp_hsz" id="f-adp_hsz" value="{{ item.adapter.adapter_hole_size_z if item and item.adapter else 10 }}"></div>
</div>
<div class="form-row-3">
<div><label>dx <span class="label-cn">X偏移</span></label><input type="number" step="any" name="adp_dx" id="f-adp_dx" value="{{ item.adapter.dx if item and item.adapter and item.adapter.dx is not none else '' }}"></div>
<div><label>dy <span class="label-cn">Y偏移</span></label><input type="number" step="any" name="adp_dy" id="f-adp_dy" value="{{ item.adapter.dy if item and item.adapter and item.adapter.dy is not none else '' }}"></div>
<div><label>dz <span class="label-cn">Z偏移</span></label><input type="number" step="any" name="adp_dz" id="f-adp_dz" value="{{ item.adapter.dz if item and item.adapter else 0 }}"></div>
</div>
</div>
<!-- Registry -->
<div class="form-section">
<h3>Registry / Template</h3>
<div class="form-row">
<label>registry_category (逗号分隔)</label>
<input type="text" name="reg_cat" id="f-reg_cat"
value="{{ item.registry_category | join(',') if item else 'prcxi,plates' }}">
</div>
<div class="form-row">
<label>registry_description</label>
<input type="text" name="reg_desc" id="f-reg_desc"
value="{{ item.registry_description if item else '' }}">
</div>
<div class="form-row">
<label><input type="checkbox" name="in_tpl" id="f-in_tpl" {% if item and item.include_in_template_matching %}checked{% endif %}> include_in_template_matching</label>
</div>
<div class="form-row" id="row-tpl_kind">
<label>template_kind</label>
<input type="text" name="tpl_kind" id="f-tpl_kind"
value="{{ item.template_kind if item and item.template_kind else '' }}">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="saveForm()">
{% if is_new %}创建{% else %}保存{% endif %}
</button>
<a href="/" class="btn btn-outline">取消</a>
</div>
</form>
</div>
<!-- 右侧: 实时预览 -->
<div class="edit-preview">
<div class="viz-card">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
预览: 俯视图
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
</h3>
<div id="svg-topdown"></div>
</div>
<div class="viz-card">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
预览: 侧面截面图
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
</h3>
<div id="svg-side"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/labware_viz.js"></script>
<script src="/static/form_handler.js"></script>
<script>
const IS_NEW = {{ 'true' if is_new else 'false' }};
const ITEM_ID = "{{ item.function_name if item else '' }}";
document.addEventListener('DOMContentLoaded', () => {
onTypeChange();
updatePreview();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block title %}耗材列表 - PRCXI{% endblock %}
{% block content %}
<div class="page-header">
<h1>耗材列表 <span class="badge">{{ total }}</span></h1>
<div class="header-actions">
<button class="btn btn-outline" onclick="importFromCode()">从代码导入</button>
<button class="btn btn-outline" onclick="generateCode(true)">生成代码 (测试)</button>
<button class="btn btn-warning" onclick="generateCode(false)">生成代码 (正式)</button>
<a href="/labware/new" class="btn btn-primary">+ 新建耗材</a>
</div>
</div>
<div id="status-msg" class="status-msg" style="display:none;"></div>
{% set type_labels = {
"plate": "孔板 (Plate)",
"tip_rack": "吸头盒 (TipRack)",
"trash": "废弃槽 (Trash)",
"tube_rack": "管架 (TubeRack)",
"plate_adapter": "适配器 (PlateAdapter)"
} %}
{% set type_colors = {
"plate": "#3b82f6",
"tip_rack": "#10b981",
"trash": "#ef4444",
"tube_rack": "#f59e0b",
"plate_adapter": "#8b5cf6"
} %}
{% for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"] %}
{% if type_key in groups %}
<section class="type-section">
<h2>
<span class="type-dot" style="background:{{ type_colors[type_key] }}"></span>
{{ type_labels[type_key] }}
<span class="badge">{{ groups[type_key]|length }}</span>
</h2>
<div class="card-grid">
{% for item in groups[type_key] %}
<div class="labware-card" onclick="location.href='/labware/{{ item.function_name }}'">
<div class="card-header">
<span class="card-title">{{ item.function_name }}</span>
{% if item.include_in_template_matching %}
<span class="tag tag-tpl">TPL</span>
{% endif %}
</div>
<div class="card-body">
<div class="card-info">
<span class="label">Code:</span> {{ item.material_info.Code or '-' }}
</div>
<div class="card-info">
<span class="label">名称:</span> {{ item.material_info.Name or '-' }}
</div>
<div class="card-info">
<span class="label">尺寸:</span>
{{ item.size_x }} x {{ item.size_y }} x {{ item.size_z }} mm
</div>
{% if item.grid %}
<div class="card-info">
<span class="label">网格:</span>
{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}
</div>
{% endif %}
</div>
<div class="card-footer">
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-sm btn-outline"
onclick="event.stopPropagation()">编辑</a>
<button class="btn btn-sm btn-danger"
onclick="event.stopPropagation(); deleteItem('{{ item.function_name }}')">删除</button>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{% endblock %}
{% block scripts %}
<script>
function showMsg(text, ok) {
const el = document.getElementById('status-msg');
el.textContent = text;
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 4000);
}
async function importFromCode() {
if (!confirm('将从现有 prcxi_labware.py + YAML 重新导入,覆盖当前 JSON 数据?')) return;
const r = await fetch('/api/import-from-code', {method:'POST'});
const d = await r.json();
if (d.status === 'ok') {
showMsg('导入成功: ' + d.count + ' 个耗材', true);
setTimeout(() => location.reload(), 1000);
} else {
showMsg('导入失败: ' + JSON.stringify(d), false);
}
}
async function generateCode(testMode) {
const label = testMode ? '测试' : '正式';
if (!testMode && !confirm('正式模式将覆盖原有 prcxi_labware.py 和 YAML 文件,确定?')) return;
const r = await fetch('/api/generate-code', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({test_mode: testMode})
});
const d = await r.json();
if (d.status === 'ok') {
showMsg(`[${label}] 生成成功: ${d.python_file}`, true);
} else {
showMsg('生成失败: ' + JSON.stringify(d), false);
}
}
async function deleteItem(id) {
if (!confirm('确定删除 ' + id + '')) return;
const r = await fetch('/api/labware/' + id, {method:'DELETE'});
const d = await r.json();
if (d.status === 'ok') {
showMsg('已删除', true);
setTimeout(() => location.reload(), 500);
} else {
showMsg('删除失败', false);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,119 @@
"""JSON → Registry YAML 文件生成。
按 type 分组输出到对应 YAML 文件(与现有格式完全一致)。
"""
from __future__ import annotations
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Dict, List
import yaml
from unilabos.labware_manager.models import LabwareDB, LabwareItem
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
# type → yaml 文件名
_TYPE_TO_YAML = {
"plate": "plates",
"tip_rack": "tip_racks",
"trash": "trash",
"tube_rack": "tube_racks",
"plate_adapter": "plate_adapters",
}
def _build_entry(item: LabwareItem) -> dict:
"""构建单个 YAML 条目(与现有格式完全一致)。"""
mi = item.material_info
desc = item.registry_description
if not desc:
desc = f'{mi.Name} (Code: {mi.Code})' if mi.Name and mi.Code else item.function_name
return {
"category": list(item.registry_category),
"class": {
"module": f"unilabos.devices.liquid_handling.prcxi.prcxi_labware:{item.function_name}",
"type": "pylabrobot",
},
"description": desc,
"handles": [],
"icon": "",
"init_param_schema": {},
"version": "1.0.0",
}
class _YAMLDumper(yaml.SafeDumper):
"""自定义 Dumper: 空列表输出为 [],空字典输出为 {}"""
pass
def _represent_list(dumper, data):
if not data:
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
return dumper.represent_sequence("tag:yaml.org,2002:seq", data)
def _represent_dict(dumper, data):
if not data:
return dumper.represent_mapping("tag:yaml.org,2002:map", data, flow_style=True)
return dumper.represent_mapping("tag:yaml.org,2002:map", data)
def _represent_str(dumper, data):
if '\n' in data or ':' in data or "'" in data:
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
_YAMLDumper.add_representer(list, _represent_list)
_YAMLDumper.add_representer(dict, _represent_dict)
_YAMLDumper.add_representer(str, _represent_str)
def generate_yaml(db: LabwareDB, test_mode: bool = True) -> List[Path]:
"""生成所有 registry YAML 文件,返回输出文件路径列表。"""
suffix = "_test" if test_mode else ""
# 按 type 分组
groups: Dict[str, Dict[str, dict]] = defaultdict(dict)
for item in db.items:
yaml_key = _TYPE_TO_YAML.get(item.type)
if yaml_key is None:
continue
groups[yaml_key][item.function_name] = _build_entry(item)
out_paths: List[Path] = []
for yaml_key, entries in groups.items():
out_path = _REGISTRY_DIR / f"{yaml_key}{suffix}.yaml"
# 备份
if out_path.exists():
bak = out_path.with_suffix(".yaml.bak")
shutil.copy2(out_path, bak)
# 按函数名排序
sorted_entries = dict(sorted(entries.items()))
content = yaml.dump(sorted_entries, Dumper=_YAMLDumper, allow_unicode=True,
default_flow_style=False, sort_keys=False)
out_path.write_text(content, encoding="utf-8")
out_paths.append(out_path)
return out_paths
if __name__ == "__main__":
from unilabos.labware_manager.importer import load_db
db = load_db()
if not db.items:
print("labware_db.json 为空,请先运行 importer.py")
else:
paths = generate_yaml(db, test_mode=True)
print(f"已生成 {len(paths)} 个 YAML 文件:")
for p in paths:
print(f" {p}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,658 @@
"""
装饰器注册表系统
通过 @device, @action, @resource 装饰器替代 YAML 配置文件来定义设备/动作/资源注册表信息。
Usage:
from unilabos.registry.decorators import (
device, action, resource,
InputHandle, OutputHandle,
ActionInputHandle, ActionOutputHandle,
HardwareInterface, Side, DataSource, NodeType,
)
@device(
id="solenoid_valve.mock",
category=["pump_and_valve"],
description="模拟电磁阀设备",
handles=[
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH),
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH),
],
hardware_interface=HardwareInterface(
name="hardware_interface",
read="send_command",
write="send_command",
),
)
class SolenoidValveMock:
@action(action_type=EmptyIn)
def close(self):
...
@action(
handles=[
ActionInputHandle(key="in", data_type="fluid", label="in"),
ActionOutputHandle(key="out", data_type="fluid", label="out"),
],
)
def set_valve_position(self, position):
...
# 无 @action 装饰器 => auto- 前缀动作
def is_open(self):
...
"""
from enum import Enum
from functools import wraps
from typing import Any, Callable, Dict, List, Optional, TypeVar
from pydantic import BaseModel, ConfigDict, Field
F = TypeVar("F", bound=Callable[..., Any])
# ---------------------------------------------------------------------------
# 枚举
# ---------------------------------------------------------------------------
class Side(str, Enum):
"""UI 上 Handle 的显示位置"""
NORTH = "NORTH"
SOUTH = "SOUTH"
EAST = "EAST"
WEST = "WEST"
class DataSource(str, Enum):
"""Handle 的数据来源"""
HANDLE = "handle" # 从上游 handle 获取数据 (用于 InputHandle)
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
class NodeType(str, Enum):
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
ILAB = "ILab"
MANUAL_CONFIRM = "manual_confirm"
# ---------------------------------------------------------------------------
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
# ---------------------------------------------------------------------------
class _DeviceHandleBase(BaseModel):
"""设备/资源端口基类 (内部使用)"""
model_config = ConfigDict(populate_by_name=True)
key: str = Field(serialization_alias="handler_key")
data_type: str
label: str
side: Optional[Side] = None
data_key: Optional[str] = None
data_source: Optional[str] = None
description: Optional[str] = None
# 子类覆盖
io_type: str = ""
def to_registry_dict(self) -> Dict[str, Any]:
return self.model_dump(by_alias=True, exclude_none=True)
class InputHandle(_DeviceHandleBase):
"""
输入端口 (io_type="target"), 用于 @device / @resource handles
Example:
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH)
"""
io_type: str = "target"
class OutputHandle(_DeviceHandleBase):
"""
输出端口 (io_type="source"), 用于 @device / @resource handles
Example:
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH)
"""
io_type: str = "source"
# ---------------------------------------------------------------------------
# Action Handle (动作级别端口, 序列化时不含 io_type, 按类型自动分组)
# ---------------------------------------------------------------------------
class _ActionHandleBase(BaseModel):
"""动作端口基类 (内部使用)"""
model_config = ConfigDict(populate_by_name=True)
key: str = Field(serialization_alias="handler_key")
data_type: str
label: str
side: Optional[Side] = None
data_key: Optional[str] = None
data_source: Optional[str] = None
description: Optional[str] = None
io_type: Optional[str] = None # source/sink (dataflow) or target/source (device-style)
def to_registry_dict(self) -> Dict[str, Any]:
return self.model_dump(by_alias=True, exclude_none=True)
class ActionInputHandle(_ActionHandleBase):
"""
动作输入端口, 用于 @action handles, 序列化后归入 "input"
Example:
ActionInputHandle(
key="material_input", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source="handle",
)
"""
pass
class ActionOutputHandle(_ActionHandleBase):
"""
动作输出端口, 用于 @action handles, 序列化后归入 "output"
Example:
ActionOutputHandle(
key="station_output", data_type="workbench_station",
label="加热台ID", data_key="station_id", data_source="executor",
)
"""
pass
# ---------------------------------------------------------------------------
# HardwareInterface
# ---------------------------------------------------------------------------
class HardwareInterface(BaseModel):
"""
硬件通信接口定义
描述设备与底层硬件通信的方式 (串口、Modbus 等)。
Example:
HardwareInterface(name="hardware_interface", read="send_command", write="send_command")
"""
name: str
read: Optional[str] = None
write: Optional[str] = None
extra_info: Optional[List[str]] = None
# ---------------------------------------------------------------------------
# 全局注册表 -- 记录所有被装饰器标记的类/函数
# ---------------------------------------------------------------------------
_registered_devices: Dict[str, type] = {} # device_id -> class
_registered_resources: Dict[str, Any] = {} # resource_id -> class or function
def _device_handles_to_list(
handles: Optional[List[_DeviceHandleBase]],
) -> List[Dict[str, Any]]:
"""将设备/资源 Handle 列表序列化为字典列表 (含 io_type)"""
if handles is None:
return []
return [h.to_registry_dict() for h in handles]
def _action_handles_to_dict(
handles: Optional[List[_ActionHandleBase]],
) -> Dict[str, Any]:
"""
将动作 Handle 列表序列化为 {"input": [...], "output": [...]} 格式。
ActionInputHandle => "input", ActionOutputHandle => "output"
"""
if handles is None:
return {}
input_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionInputHandle)]
output_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionOutputHandle)]
result: Dict[str, Any] = {}
if input_list:
result["input"] = input_list
if output_list:
result["output"] = output_list
return result
# ---------------------------------------------------------------------------
# @device 类装饰器
# ---------------------------------------------------------------------------
# noinspection PyShadowingBuiltins
def device(
id: Optional[str] = None,
ids: Optional[List[str]] = None,
id_meta: Optional[Dict[str, Dict[str, Any]]] = None,
category: Optional[List[str]] = None,
description: str = "",
display_name: str = "",
icon: str = "",
version: str = "1.0.0",
handles: Optional[List[_DeviceHandleBase]] = None,
model: Optional[Dict[str, Any]] = None,
device_type: str = "python",
hardware_interface: Optional[HardwareInterface] = None,
):
"""
设备类装饰器
将类标记为一个 UniLab-OS 设备,并附加注册表元数据。
支持两种模式:
1. 单设备: id="xxx", category=[...]
2. 多设备: ids=["id1","id2"], id_meta={"id1":{handles:[...]}, "id2":{...}}
Args:
id: 单设备时的注册表唯一标识
ids: 多设备时的 id 列表,与 id_meta 配合使用
id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model)
category: 设备分类标签列表 (必填)
description: 设备描述
display_name: 人类可读的设备显示名称,缺失时默认使用 id
icon: 图标路径
version: 版本号
handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用)
model: 可选的 3D 模型配置
device_type: 设备实现类型 ("python" / "ros2")
hardware_interface: 硬件通信接口 (HardwareInterface)
"""
# Resolve device ids
if ids is not None:
device_ids = list(ids)
if not device_ids:
raise ValueError("@device ids 不能为空")
id_meta = id_meta or {}
elif id is not None:
device_ids = [id]
id_meta = {}
else:
raise ValueError("@device 必须提供 id 或 ids")
if category is None:
raise ValueError("@device category 必填")
base_meta = {
"category": category,
"description": description,
"display_name": display_name,
"icon": icon,
"version": version,
"handles": _device_handles_to_list(handles),
"model": model,
"device_type": device_type,
"hardware_interface": (hardware_interface.model_dump(exclude_none=True) if hardware_interface else None),
}
def decorator(cls):
cls._device_registry_meta = base_meta
cls._device_registry_id_meta = id_meta
cls._device_registry_ids = device_ids
for did in device_ids:
if did in _registered_devices:
raise ValueError(f"@device id 重复: '{did}' 已被 {_registered_devices[did]} 注册")
_registered_devices[did] = cls
return cls
return decorator
# ---------------------------------------------------------------------------
# @action 方法装饰器
# ---------------------------------------------------------------------------
# 区分 "用户没传 action_type" 和 "用户传了 None"
_ACTION_TYPE_UNSET = object()
# noinspection PyShadowingNames
def action(
action_type: Any = _ACTION_TYPE_UNSET,
goal: Optional[Dict[str, str]] = None,
feedback: Optional[Dict[str, str]] = None,
result: Optional[Dict[str, str]] = None,
handles: Optional[List[_ActionHandleBase]] = None,
goal_default: Optional[Dict[str, Any]] = None,
placeholder_keys: Optional[Dict[str, str]] = None,
always_free: bool = False,
is_protocol: bool = False,
description: str = "",
auto_prefix: bool = False,
parent: bool = False,
node_type: Optional["NodeType"] = None,
):
"""
动作方法装饰器
标记方法为注册表动作。有三种用法:
1. @action(action_type=EmptyIn, ...) -- 非 auto, 使用指定 ROS Action 类型
2. @action() -- 非 auto, UniLabJsonCommand (从方法签名生成 schema)
3. 不加 @action -- auto- 前缀, UniLabJsonCommand
Protocol 用法:
@action(action_type=Add, is_protocol=True)
def AddProtocol(self): ...
标记该动作为高级协议 (protocol),运行时通过 ROS Action 路由到
protocol generator 执行。action_type 指向 unilabos_msgs 的 Action 类型。
Args:
action_type: ROS Action 消息类型 (如 EmptyIn, SendCmd, HeatChill).
不传/默认 = UniLabJsonCommand (非 auto).
goal: Goal 字段映射 (ROS字段名 -> 设备参数名).
protocol 模式下可留空,系统自动生成 identity 映射.
feedback: Feedback 字段映射
result: Result 字段映射
handles: 动作端口列表 (ActionInputHandle / ActionOutputHandle)
goal_default: Goal 字段默认值映射 (字段名 -> 默认值), 与自动生成的 goal_default 合并
placeholder_keys: 参数占位符配置
always_free: 是否为永久闲置动作 (不受排队限制)
is_protocol: 是否为工作站协议 (protocol)。True 时运行时走 protocol generator 路径。
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:
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
meta = {
"action_type": resolved_type,
"goal": goal or {},
"feedback": feedback or {},
"result": result or {},
"handles": _action_handles_to_dict(handles),
"goal_default": goal_default or {},
"placeholder_keys": placeholder_keys or {},
"always_free": always_free,
"is_protocol": is_protocol,
"description": description,
"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 装饰器兼容
if always_free:
wrapper._is_always_free = True # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
return decorator
def get_action_meta(func) -> Optional[Dict[str, Any]]:
"""获取方法上的 @action 装饰器元数据"""
return getattr(func, "_action_registry_meta", None)
def has_action_decorator(func) -> bool:
"""检查函数是否带有 @action 装饰器"""
return hasattr(func, "_action_registry_meta")
# ---------------------------------------------------------------------------
# @resource 类/函数装饰器
# ---------------------------------------------------------------------------
def resource(
id: str,
category: List[str],
description: str = "",
icon: str = "",
version: str = "1.0.0",
handles: Optional[List[_DeviceHandleBase]] = None,
model: Optional[Dict[str, Any]] = None,
class_type: str = "pylabrobot",
):
"""
资源类/函数装饰器
将类或工厂函数标记为一个 UniLab-OS 资源,附加注册表元数据。
Args:
id: 注册表唯一标识 (必填, 不可重复)
category: 资源分类标签列表 (必填)
description: 资源描述
icon: 图标路径
version: 版本号
handles: 端口列表 (InputHandle / OutputHandle)
model: 可选的 3D 模型配置
class_type: 资源实现类型 ("python" / "pylabrobot" / "unilabos")
"""
def decorator(obj):
meta = {
"resource_id": id,
"category": category,
"description": description,
"icon": icon,
"version": version,
"handles": _device_handles_to_list(handles),
"model": model,
"class_type": class_type,
}
obj._resource_registry_meta = meta
if id in _registered_resources:
raise ValueError(f"@resource id 重复: '{id}' 已被 {_registered_resources[id]} 注册")
_registered_resources[id] = obj
return obj
return decorator
def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
获取类上的 @device 装饰器元数据。
当 device_id 存在且类使用 ids+id_meta 时,返回合并后的 meta
(base_meta 与 id_meta[device_id] 深度合并)。
"""
base = getattr(cls, "_device_registry_meta", None)
if base is None:
return None
id_meta = getattr(cls, "_device_registry_id_meta", None) or {}
if device_id is None or device_id not in id_meta:
result = dict(base)
ids = getattr(cls, "_device_registry_ids", None)
result["device_id"] = device_id if device_id is not None else (ids[0] if ids else None)
return result
overrides = id_meta[device_id]
result = dict(base)
result["device_id"] = device_id
for key in ["handles", "description", "icon", "model"]:
if key in overrides:
val = overrides[key]
if key == "handles" and isinstance(val, list):
# handles 必须是 Handle 对象列表
result[key] = [h.to_registry_dict() for h in val]
else:
result[key] = val
return result
def get_resource_meta(obj) -> Optional[Dict[str, Any]]:
"""获取对象上的 @resource 装饰器元数据"""
return getattr(obj, "_resource_registry_meta", None)
def get_all_registered_devices() -> Dict[str, type]:
"""获取所有已注册的设备类"""
return _registered_devices.copy()
def get_all_registered_resources() -> Dict[str, Any]:
"""获取所有已注册的资源"""
return _registered_resources.copy()
def clear_registry():
"""清空全局注册表 (用于测试)"""
_registered_devices.clear()
_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 装饰器
# ---------------------------------------------------------------------------
def topic_config(
period: Optional[float] = None,
print_publish: Optional[bool] = None,
qos: Optional[int] = None,
name: Optional[str] = None,
) -> Callable[[F], F]:
"""
Topic发布配置装饰器
用于装饰 get_{attr_name} 方法或 @property控制对应属性的ROS topic发布行为。
Args:
period: 发布周期。None 表示使用默认值 5.0
print_publish: 是否打印发布日志。None 表示使用节点默认配置
qos: QoS深度配置。None 表示使用默认值 10
name: 自定义发布名称。None 表示使用方法名(去掉 get_ 前缀)
Note:
与 @property 连用时,@topic_config 必须放在 @property 下面,
这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。
"""
def decorator(func: F) -> F:
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper._topic_period = period # type: ignore[attr-defined]
wrapper._topic_print_publish = print_publish # type: ignore[attr-defined]
wrapper._topic_qos = qos # type: ignore[attr-defined]
wrapper._topic_name = name # type: ignore[attr-defined]
wrapper._has_topic_config = True # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
return decorator
def get_topic_config(func) -> dict:
"""获取函数上的 topic 配置 (period, print_publish, qos, name)"""
if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False):
return {
"period": getattr(func, "_topic_period", None),
"print_publish": getattr(func, "_topic_print_publish", None),
"qos": getattr(func, "_topic_qos", None),
"name": getattr(func, "_topic_name", None),
}
return {}
def always_free(func: F) -> F:
"""
标记动作为永久闲置(不受busy队列限制)的装饰器
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
"""
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper._is_always_free = True # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
def is_always_free(func) -> bool:
"""检查函数是否被标记为永久闲置"""
return getattr(func, "_is_always_free", False)
def not_action(func: F) -> F:
"""
标记方法为非动作的装饰器
用于装饰 driver 类中的方法,使其在注册表扫描时不被识别为动作。
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
"""
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper._is_not_action = True # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
def is_not_action(func) -> bool:
"""检查函数是否被标记为非动作"""
return getattr(func, "_is_not_action", False)

View File

@@ -96,10 +96,13 @@ serial:
type: string
port:
type: string
registry_name:
type: string
resource_tracker:
type: object
required:
- device_id
- registry_name
- port
type: object
data:

View File

@@ -13,21 +13,18 @@ Qone_nmr:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -71,31 +68,6 @@ Qone_nmr:
title: monitor_folder_for_new_content参数
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: string
required:
- ros_node
type: object
result: {}
required:
- goal
title: post_init参数
type: object
type: UniLabJsonCommand
auto-strings_to_txt:
feedback: {}
goal: {}
@@ -138,21 +110,18 @@ Qone_nmr:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -167,32 +136,31 @@ Qone_nmr:
goal_default:
string: ''
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: StrSingleInput_Feedback
type: object
goal:
additionalProperties: false
properties:
string:
type: string
required:
- string
title: StrSingleInput_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: StrSingleInput_Result
type: object
required:

File diff suppressed because it is too large Load Diff

View File

@@ -24,39 +24,321 @@ bioyond_dispensing_station:
required:
- data
type: object
result: {}
result:
type: object
required:
- goal
title: brief_step_parameters参数
type: object
type: UniLabJsonCommand
auto-compute_experiment_design:
auto-process_order_finish_report:
feedback: {}
goal: {}
goal_default:
m_tot: '70'
ratio: null
titration_percent: '0.03'
wt_percent: '0.25'
report_request: null
used_materials: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
report_request:
type: string
used_materials:
type: string
required:
- report_request
- used_materials
type: object
result:
type: object
required:
- goal
title: process_order_finish_report参数
type: object
type: UniLabJsonCommand
auto-project_order_report:
feedback: {}
goal: {}
goal_default:
order_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
order_id:
type: string
required:
- order_id
type: object
result:
type: object
required:
- goal
title: project_order_report参数
type: object
type: UniLabJsonCommand
auto-query_resource_by_name:
feedback: {}
goal: {}
goal_default:
material_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
material_name:
type: string
required:
- material_name
type: object
result: {}
required:
- goal
title: query_resource_by_name参数
type: object
type: UniLabJsonCommand
auto-workflow_sample_locations:
feedback: {}
goal: {}
goal_default:
workflow_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
workflow_id:
type: string
required:
- workflow_id
type: object
result:
type: object
required:
- goal
title: workflow_sample_locations参数
type: object
type: UniLabJsonCommand
batch_create_90_10_vial_feeding_tasks:
feedback: {}
goal:
delay_time: delay_time
hold_m_name: hold_m_name
liquid_material_name: liquid_material_name
speed: speed
temperature: temperature
titration: titration
goal_default:
delay_time: null
hold_m_name: null
liquid_material_name: NMP
speed: null
temperature: null
titration: null
handles:
input:
- data_key: titration
data_source: handle
data_type: object
handler_key: titration
io_type: source
label: Titration Data From Calculation Node
output:
- data_key: return_info
data_source: executor
data_type: string
handler_key: BATCH_CREATE_RESULT
io_type: sink
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
placeholder_keys: {}
result: {}
schema:
description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。
properties:
feedback:
title: BatchCreate9010VialFeedingTasks_Feedback
goal:
properties:
delay_time:
description: 延迟时间(秒),默认600
type: string
hold_m_name:
description: 库位名称,如"C01",必填参数
type: string
liquid_material_name:
default: NMP
description: 10%物料的液体物料名称,默认为"NMP"
type: string
speed:
description: 搅拌速度,默认400
type: string
temperature:
description: 温度(℃),默认40
type: string
titration:
description: '滴定信息对象,包含: name(物料名称), main_portion(主称固体质量g), titration_portion(滴定固体质量g),
titration_solvent(滴定溶液体积mL)'
type: string
required:
- titration
title: BatchCreate9010VialFeedingTasks_Goal
type: object
result:
title: BatchCreate9010VialFeedingTasks_Result
type: string
required:
- goal
title: batch_create_90_10_vial_feeding_tasks参数
type: object
type: UniLabJsonCommand
batch_create_diamine_solution_tasks:
feedback: {}
goal:
delay_time: delay_time
liquid_material_name: liquid_material_name
solutions: solutions
speed: speed
temperature: temperature
goal_default:
delay_time: null
liquid_material_name: NMP
solutions: null
speed: null
temperature: null
handles:
input:
- data_key: solutions
data_source: handle
data_type: array
handler_key: solutions
io_type: source
label: Solution Data From Python
output:
- data_key: return_info
data_source: executor
data_type: string
handler_key: BATCH_CREATE_RESULT
io_type: sink
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
placeholder_keys: {}
result: {}
schema:
description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。
properties:
feedback:
title: BatchCreateDiamineSolutionTasks_Feedback
goal:
properties:
delay_time:
description: 溶液配置完成后的延迟时间用于充分混合和溶解默认600秒
type: string
liquid_material_name:
default: NMP
description: 液体溶剂名称用于溶解固体物料默认为NMPN-甲基吡咯烷酮)
type: string
solutions:
description: '溶液列表JSON数组格式每个元素包含: name(物料名称), order(序号), solid_mass(固体质量g),
solvent_volume(溶剂体积mL)。示例: [{"name": "MDA", "order": 0, "solid_mass":
5.0, "solvent_volume": 20}, {"name": "MPDA", "order": 1, "solid_mass":
4.5, "solvent_volume": 18}]'
type: string
speed:
description: 搅拌速度rpm用于混合溶液默认400转/分钟
type: string
temperature:
description: 配置温度溶液配置过程的目标温度默认20℃室温
type: string
required:
- solutions
title: BatchCreateDiamineSolutionTasks_Goal
type: object
result:
title: BatchCreateDiamineSolutionTasks_Result
type: string
required:
- goal
title: batch_create_diamine_solution_tasks参数
type: object
type: UniLabJsonCommand
compute_experiment_design:
feedback: {}
goal:
m_tot: m_tot
ratio: ratio
titration_percent: titration_percent
wt_percent: wt_percent
goal_default:
m_tot: '70'
ratio: null
titration_percent: '0.03'
wt_percent: '0.25'
handles:
output:
- data_key: solutions
data_source: executor
data_type: array
handler_key: solutions
io_type: sink
label: Solution Data From Python
- data_key: titration
data_source: executor
data_type: object
handler_key: titration
io_type: sink
label: Titration Data From Calculation Node
- data_key: solvents
data_source: executor
data_type: object
handler_key: solvents
io_type: sink
label: Solvents Data From Calculation Node
- data_key: feeding_order
data_source: executor
data_type: array
handler_key: feeding_order
io_type: sink
label: Feeding Order Data From Calculation Node
placeholder_keys: {}
result: {}
schema:
description: 计算实验设计输出solutions/titration/solvents/feeding_order用于后续节点。
properties:
feedback: {}
goal:
properties:
m_tot:
default: '70'
description: 总质量(g)
type: string
ratio:
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
type: object
titration_percent:
default: '0.03'
description: 滴定比例(10%部分)
type: string
wt_percent:
default: '0.25'
description: 目标固含质量分数
type: string
required:
- ratio
@@ -95,305 +377,6 @@ bioyond_dispensing_station:
title: compute_experiment_design参数
type: object
type: UniLabJsonCommand
auto-process_order_finish_report:
feedback: {}
goal: {}
goal_default:
report_request: null
used_materials: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
report_request:
type: string
used_materials:
type: string
required:
- report_request
- used_materials
type: object
result: {}
required:
- goal
title: process_order_finish_report参数
type: object
type: UniLabJsonCommand
auto-project_order_report:
feedback: {}
goal: {}
goal_default:
order_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
order_id:
type: string
required:
- order_id
type: object
result: {}
required:
- goal
title: project_order_report参数
type: object
type: UniLabJsonCommand
auto-query_resource_by_name:
feedback: {}
goal: {}
goal_default:
material_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
material_name:
type: string
required:
- material_name
type: object
result: {}
required:
- goal
title: query_resource_by_name参数
type: object
type: UniLabJsonCommand
auto-transfer_materials_to_reaction_station:
feedback: {}
goal: {}
goal_default:
target_device_id: null
transfer_groups: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
target_device_id:
type: string
transfer_groups:
type: array
required:
- target_device_id
- transfer_groups
type: object
result: {}
required:
- goal
title: transfer_materials_to_reaction_station参数
type: object
type: UniLabJsonCommand
auto-workflow_sample_locations:
feedback: {}
goal: {}
goal_default:
workflow_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
workflow_id:
type: string
required:
- workflow_id
type: object
result: {}
required:
- goal
title: workflow_sample_locations参数
type: object
type: UniLabJsonCommand
batch_create_90_10_vial_feeding_tasks:
feedback: {}
goal:
delay_time: delay_time
hold_m_name: hold_m_name
liquid_material_name: liquid_material_name
speed: speed
temperature: temperature
titration: titration
goal_default:
delay_time: '600'
hold_m_name: ''
liquid_material_name: NMP
speed: '400'
temperature: '40'
titration: ''
handles:
input:
- data_key: titration
data_source: handle
data_type: object
handler_key: titration
io_type: source
label: Titration Data From Calculation Node
output:
- data_key: return_info
data_source: executor
data_type: string
handler_key: BATCH_CREATE_RESULT
io_type: sink
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
result:
return_info: return_info
schema:
description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。
properties:
feedback:
properties: {}
required: []
title: BatchCreate9010VialFeedingTasks_Feedback
type: object
goal:
properties:
delay_time:
default: '600'
description: 延迟时间(秒),默认600
type: string
hold_m_name:
description: 库位名称,如"C01",必填参数
type: string
liquid_material_name:
default: NMP
description: 10%物料的液体物料名称,默认为"NMP"
type: string
speed:
default: '400'
description: 搅拌速度,默认400
type: string
temperature:
default: '40'
description: 温度(℃),默认40
type: string
titration:
description: '滴定信息对象,包含: name(物料名称), main_portion(主称固体质量g), titration_portion(滴定固体质量g),
titration_solvent(滴定溶液体积mL)'
type: string
required:
- titration
- hold_m_name
title: BatchCreate9010VialFeedingTasks_Goal
type: object
result:
properties:
return_info:
description: 批量任务创建结果汇总JSON字符串包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
type: string
required:
- return_info
title: BatchCreate9010VialFeedingTasks_Result
type: object
required:
- goal
title: BatchCreate9010VialFeedingTasks
type: object
type: UniLabJsonCommand
batch_create_diamine_solution_tasks:
feedback: {}
goal:
delay_time: delay_time
liquid_material_name: liquid_material_name
solutions: solutions
speed: speed
temperature: temperature
goal_default:
delay_time: '600'
liquid_material_name: NMP
solutions: ''
speed: '400'
temperature: '20'
handles:
input:
- data_key: solutions
data_source: handle
data_type: array
handler_key: solutions
io_type: source
label: Solution Data From Python
output:
- data_key: return_info
data_source: executor
data_type: string
handler_key: BATCH_CREATE_RESULT
io_type: sink
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
result:
return_info: return_info
schema:
description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。
properties:
feedback:
properties: {}
required: []
title: BatchCreateDiamineSolutionTasks_Feedback
type: object
goal:
properties:
delay_time:
default: '600'
description: 溶液配置完成后的延迟时间用于充分混合和溶解默认600秒
type: string
liquid_material_name:
default: NMP
description: 液体溶剂名称用于溶解固体物料默认为NMPN-甲基吡咯烷酮)
type: string
solutions:
description: '溶液列表JSON数组格式每个元素包含: name(物料名称), order(序号), solid_mass(固体质量g),
solvent_volume(溶剂体积mL)。示例: [{"name": "MDA", "order": 0, "solid_mass":
5.0, "solvent_volume": 20}, {"name": "MPDA", "order": 1, "solid_mass":
4.5, "solvent_volume": 18}]'
type: string
speed:
default: '400'
description: 搅拌速度rpm用于混合溶液默认400转/分钟
type: string
temperature:
default: '20'
description: 配置温度溶液配置过程的目标温度默认20℃室温
type: string
required:
- solutions
title: BatchCreateDiamineSolutionTasks_Goal
type: object
result:
properties:
return_info:
description: 批量任务创建结果汇总JSON字符串包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
type: string
required:
- return_info
title: BatchCreateDiamineSolutionTasks_Result
type: object
required:
- goal
title: BatchCreateDiamineSolutionTasks
type: object
type: UniLabJsonCommand
create_90_10_vial_feeding_task:
feedback: {}
goal:
@@ -445,17 +428,18 @@ bioyond_dispensing_station:
speed: ''
temperature: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: DispenStationVialFeed_Feedback
type: object
goal:
additionalProperties: false
properties:
delay_time:
type: string
@@ -503,38 +487,13 @@ bioyond_dispensing_station:
type: string
temperature:
type: string
required:
- order_name
- percent_90_1_assign_material_name
- percent_90_1_target_weigh
- percent_90_2_assign_material_name
- percent_90_2_target_weigh
- percent_90_3_assign_material_name
- percent_90_3_target_weigh
- percent_10_1_assign_material_name
- percent_10_1_target_weigh
- percent_10_1_volume
- percent_10_1_liquid_material_name
- percent_10_2_assign_material_name
- percent_10_2_target_weigh
- percent_10_2_volume
- percent_10_2_liquid_material_name
- percent_10_3_assign_material_name
- percent_10_3_target_weigh
- percent_10_3_volume
- percent_10_3_liquid_material_name
- speed
- temperature
- delay_time
- hold_m_name
title: DispenStationVialFeed_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: DispenStationVialFeed_Result
type: object
required:
@@ -565,17 +524,18 @@ bioyond_dispensing_station:
temperature: ''
volume: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: DispenStationSolnPrep_Feedback
type: object
goal:
additionalProperties: false
properties:
delay_time:
type: string
@@ -595,24 +555,13 @@ bioyond_dispensing_station:
type: string
volume:
type: string
required:
- order_name
- material_name
- target_weigh
- volume
- liquid_material_name
- speed
- temperature
- delay_time
- hold_m_name
title: DispenStationSolnPrep_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: DispenStationSolnPrep_Result
type: object
required:
@@ -620,6 +569,64 @@ bioyond_dispensing_station:
title: DispenStationSolnPrep
type: object
type: DispenStationSolnPrep
scheduler_start:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 启动调度器 - 启动Bioyond配液站的任务调度器开始执行队列中的任务
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
title: scheduler_start结果
type: object
required:
- goal
title: scheduler_start参数
type: object
type: UniLabJsonCommand
transfer_materials_to_reaction_station:
feedback: {}
goal:
target_device_id: target_device_id
transfer_groups: transfer_groups
goal_default:
target_device_id: null
transfer_groups: null
handles: {}
placeholder_keys:
target_device_id: unilabos_devices
result: {}
schema:
description: 将配液站完成的物料(溶液、样品等)转移到指定反应站的堆栈库位。支持配置多组转移任务,每组包含物料名称、目标堆栈和目标库位。
properties:
feedback: {}
goal:
properties:
target_device_id:
description: 目标反应站设备ID从设备列表中选择所有转移组都使用同一个目标设备
type: string
transfer_groups:
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
type: array
required:
- target_device_id
- transfer_groups
type: object
result:
type: object
required:
- goal
title: transfer_materials_to_reaction_station参数
type: object
type: UniLabJsonCommand
wait_for_multiple_orders_and_get_reports:
feedback: {}
goal:
@@ -627,9 +634,9 @@ bioyond_dispensing_station:
check_interval: check_interval
timeout: timeout
goal_default:
batch_create_result: ''
check_interval: '10'
timeout: '7200'
batch_create_result: null
check_interval: 10
timeout: 7200
handles:
input:
- data_key: batch_create_result
@@ -645,50 +652,38 @@ bioyond_dispensing_station:
handler_key: batch_reports_result
io_type: sink
label: Batch Order Completion Reports
result:
return_info: return_info
placeholder_keys: {}
result: {}
schema:
description: 同时等待多个任务完成并获取所有实验报告。从上游batch_create任务接收包含order_codes和order_ids的结果对象并行监控所有任务状态并返回每个任务的报告。
properties:
feedback:
properties: {}
required: []
title: WaitForMultipleOrdersAndGetReports_Feedback
type: object
goal:
properties:
batch_create_result:
description: 批量创建任务的返回结果对象包含order_codes和order_ids数组。从上游batch_create节点通过handle传递
type: string
check_interval:
default: '10'
default: 10
description: 检查任务状态的时间间隔默认每10秒检查一次所有待完成任务
type: string
type: integer
timeout:
default: '7200'
default: 7200
description: 等待超时时间默认7200秒2小时。超过此时间未完成的任务将标记为timeout
type: string
required:
- batch_create_result
type: integer
required: []
title: WaitForMultipleOrdersAndGetReports_Goal
type: object
result:
properties:
return_info:
description: 'JSON格式的批量任务完成信息包含: total(总数), completed(成功数), timeout(超时数),
error(错误数), elapsed_time(总耗时), reports(报告数组每个元素包含order_code,
order_id, status, completion_status, report, elapsed_time)'
type: string
required:
- return_info
title: WaitForMultipleOrdersAndGetReports_Result
type: object
required:
- goal
title: WaitForMultipleOrdersAndGetReports
title: wait_for_multiple_orders_and_get_reports参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
module: unilabos.devices.workstation.bioyond_studio.dispensing_station.dispensing_station:BioyondDispensingStation
status_types: {}
type: python
config_info: []
@@ -699,15 +694,16 @@ bioyond_dispensing_station:
config:
properties:
config:
type: string
type: object
deck:
type: string
required:
- config
- deck
protocol_type:
type: string
required: []
type: object
data:
properties: {}
required: []
type: object
model: {}
version: 1.0.0

View File

@@ -1,78 +0,0 @@
camera:
category:
- camera
class:
action_value_mappings:
auto-destroy_node:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 用于安全地关闭摄像头设备释放摄像头资源停止视频采集和发布服务。调用此函数将清理OpenCV摄像头连接并销毁ROS2节点。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: destroy_node参数
type: object
type: UniLabJsonCommand
auto-timer_callback:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 定时器回调函数的参数schema。此函数负责定期采集摄像头视频帧将OpenCV格式的图像转换为ROS Image消息格式并发布到指定的视频话题。默认以10Hz频率执行确保视频流的连续性和实时性。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: timer_callback参数
type: object
type: UniLabJsonCommand
module: unilabos.ros.nodes.presets.camera:VideoPublisher
status_types: {}
type: ros2
config_info: []
description: VideoPublisher摄像头设备节点用于实时视频采集和流媒体发布。该设备通过OpenCV连接本地摄像头如USB摄像头、内置摄像头等定时采集视频帧并将其转换为ROS2的sensor_msgs/Image消息格式发布到视频话题。主要用于实验室自动化系统中的视觉监控、图像分析、实时观察等应用场景。支持可配置的摄像头索引、发布频率等参数。
handles: []
icon: ''
init_param_schema:
config:
properties:
camera_index:
default: 0
type: string
device_id:
default: video_publisher
type: string
device_uuid:
default: ''
type: string
period:
default: 0.1
type: number
resource_tracker:
type: object
required: []
type: object
data:
properties: {}
required: []
type: object
version: 1.0.0

View File

@@ -18,7 +18,7 @@ cameracontroller_device:
goal:
properties:
config:
type: string
type: object
required: []
type: object
result: {}
@@ -42,7 +42,8 @@ cameracontroller_device:
properties: {}
required: []
type: object
result: {}
result:
type: object
required:
- goal
title: stop参数
@@ -50,7 +51,7 @@ cameracontroller_device:
type: UniLabJsonCommand
module: unilabos.devices.cameraSII.cameraUSB:CameraController
status_types:
status: dict
status: Dict[str, Any]
type: python
config_info: []
description: Uni-Lab-OS 摄像头驱动Linux USB 摄像头版,无 PTZ
@@ -103,5 +104,4 @@ cameracontroller_device:
required:
- status
type: object
registry_type: device
version: 1.0.0

View File

@@ -141,30 +141,26 @@ hplc.agilent:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -175,7 +171,6 @@ hplc.agilent:
module: unilabos.devices.hplc.AgilentHPLC:HPLCDriver
status_types:
could_run: bool
data_file: String
device_status: str
driver_init_ok: bool
finish_status: str
@@ -199,10 +194,6 @@ hplc.agilent:
properties:
could_run:
type: boolean
data_file:
items:
type: string
type: array
device_status:
type: string
driver_init_ok:
@@ -216,14 +207,13 @@ hplc.agilent:
success:
type: boolean
required:
- status_text
- device_status
- could_run
- device_status
- driver_init_ok
- is_running
- success
- finish_status
- data_file
- is_running
- status_text
- success
type: object
version: 1.0.0
hplc.agilent-zhida:
@@ -236,26 +226,25 @@ hplc.agilent-zhida:
goal: {}
goal_default: {}
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -315,21 +304,18 @@ hplc.agilent-zhida:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -341,35 +327,35 @@ hplc.agilent-zhida:
feedback: {}
goal:
string: string
text: text
goal_default:
string: ''
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: StrSingleInput_Feedback
type: object
goal:
additionalProperties: false
properties:
string:
type: string
required:
- string
title: StrSingleInput_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: StrSingleInput_Result
type: object
required:
@@ -407,7 +393,7 @@ hplc.agilent-zhida:
status:
type: object
required:
- status
- methods
- status
type: object
version: 1.0.0

View File

@@ -120,42 +120,41 @@ raman.home_made:
type: object
type: UniLabJsonCommand
raman_cmd:
feedback: {}
feedback:
status: status
goal:
command: command
goal_default:
command: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:

View File

@@ -19,7 +19,8 @@ separator.chinwe:
properties: {}
required: []
type: object
result: {}
result:
type: boolean
required:
- goal
title: connect参数
@@ -65,135 +66,145 @@ separator.chinwe:
required:
- command_dict
type: object
result: {}
result:
type: boolean
required:
- goal
title: execute_command_from_outer参数
type: object
type: UniLabJsonCommand
motor_rotate_quarter:
feedback: {}
goal:
direction: 顺时针
motor_id: 4
speed: 60
goal_default:
direction: 顺时针
motor_id: null
speed: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 电机旋转 1/4 圈
properties:
feedback: {}
goal:
properties:
direction:
default: 顺时针
description: 旋转方向
enum:
- 顺时针
- 逆时针
type: string
motor_id:
default: '4'
description: 选择电机 (4:搅拌, 5:旋钮)
enum:
- '4'
- '5'
type: string
type: integer
speed:
default: 60
description: 速度 (RPM)
type: integer
required:
- motor_id
- speed
type: object
result: {}
required:
- goal
title: motor_rotate_quarter参数
type: object
type: UniLabJsonCommand
motor_run_continuous:
feedback: {}
goal:
direction: 顺时针
motor_id: 4
speed: 60
goal_default:
direction: 顺时针
motor_id: null
speed: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 电机一直旋转 (速度模式)
properties:
feedback: {}
goal:
properties:
direction:
default: 顺时针
description: 旋转方向
enum:
- 顺时针
- 逆时针
type: string
motor_id:
default: '4'
description: 选择电机 (4:搅拌, 5:旋钮)
enum:
- '4'
- '5'
type: string
type: integer
speed:
default: 60
description: 速度 (RPM)
type: integer
required:
- motor_id
- speed
type: object
result: {}
required:
- goal
title: motor_run_continuous参数
type: object
type: UniLabJsonCommand
motor_stop:
feedback: {}
goal:
motor_id: 4
goal_default:
motor_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 停止指定步进电机
properties:
feedback: {}
goal:
properties:
motor_id:
default: '4'
description: 选择电机
enum:
- '4'
- '5'
title: '注: 4=搅拌, 5=旋钮'
type: string
type: integer
required:
- motor_id
type: object
result: {}
required:
- goal
title: motor_stop参数
type: object
type: UniLabJsonCommand
pump_aspirate:
feedback: {}
goal:
pump_id: 1
valve_port: 1
volume: 1000
goal_default:
pump_id: null
valve_port: null
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 注射泵吸液
properties:
feedback: {}
goal:
properties:
pump_id:
default: '1'
description: 选择泵
enum:
- '1'
- '2'
- '3'
type: string
type: integer
valve_port:
default: '1'
description: 阀门端口
enum:
- '1'
- '2'
- '3'
- '4'
- '5'
- '6'
- '7'
- '8'
type: string
type: integer
volume:
default: 1000
description: 吸液步数
type: integer
required:
@@ -201,41 +212,38 @@ separator.chinwe:
- volume
- valve_port
type: object
result: {}
required:
- goal
title: pump_aspirate参数
type: object
type: UniLabJsonCommand
pump_dispense:
feedback: {}
goal:
pump_id: 1
valve_port: 1
volume: 1000
goal_default:
pump_id: null
valve_port: null
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 注射泵排液
properties:
feedback: {}
goal:
properties:
pump_id:
default: '1'
description: 选择泵
enum:
- '1'
- '2'
- '3'
type: string
type: integer
valve_port:
default: '1'
description: 阀门端口
enum:
- '1'
- '2'
- '3'
- '4'
- '5'
- '6'
- '7'
- '8'
type: string
type: integer
volume:
default: 1000
description: 排液步数
type: integer
required:
@@ -243,121 +251,152 @@ separator.chinwe:
- volume
- valve_port
type: object
result: {}
required:
- goal
title: pump_dispense参数
type: object
type: UniLabJsonCommand
pump_initialize:
feedback: {}
goal:
drain_port: 0
output_port: 0
pump_id: 1
speed: 10
goal_default:
drain_port: 0
output_port: 0
pump_id: null
speed: 10
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 初始化指定注射泵
properties:
feedback: {}
goal:
properties:
drain_port:
default: 0
description: 排液口索引
type: integer
type: string
output_port:
default: 0
description: 输出口索引
type: integer
pump_id:
default: '1'
description: 选择泵
enum:
- '1'
- '2'
- '3'
title: '注: 1号泵, 2号泵, 3号泵'
type: string
pump_id:
description: 选择泵
title: '注: 1号泵, 2号泵, 3号泵'
type: integer
speed:
default: 10
description: 运动速度
type: integer
type: string
required:
- pump_id
type: object
result: {}
required:
- goal
title: pump_initialize参数
type: object
type: UniLabJsonCommand
pump_valve:
feedback: {}
goal:
port: 1
pump_id: 1
goal_default:
port: null
pump_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 切换指定泵的阀门端口
properties:
feedback: {}
goal:
properties:
port:
default: '1'
description: 阀门端口号 (1-8)
enum:
- '1'
- '2'
- '3'
- '4'
- '5'
- '6'
- '7'
- '8'
type: string
type: integer
pump_id:
default: '1'
description: 选择泵
enum:
- '1'
- '2'
- '3'
type: string
type: integer
required:
- pump_id
- port
type: object
result: {}
required:
- goal
title: pump_valve参数
type: object
type: UniLabJsonCommand
wait_sensor_level:
feedback: {}
goal:
target_state: 有液
timeout: 30
goal_default:
target_state: 有液
timeout: 30
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 等待传感器液位条件
properties:
feedback: {}
goal:
properties:
target_state:
default: 有液
description: 目标液位状态
enum:
- 有液
- 无液
type: string
timeout:
default: 30
description: 超时时间 (秒)
type: integer
required:
- target_state
required: []
type: object
result:
type: boolean
required:
- goal
title: wait_sensor_level参数
type: object
type: UniLabJsonCommand
wait_time:
feedback: {}
goal:
duration: 10
goal_default:
duration: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 等待指定时间
properties:
feedback: {}
goal:
properties:
duration:
default: 10
description: 等待时间 (秒)
type: integer
required:
- duration
type: object
result:
type: boolean
required:
- goal
title: wait_time参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.separator.chinwe:ChinweDevice
status_types:
@@ -406,8 +445,8 @@ separator.chinwe:
sensor_rssi:
type: integer
required:
- is_connected
- sensor_level
- sensor_rssi
- is_connected
type: object
version: 2.1.0

View File

@@ -0,0 +1,834 @@
coincellassemblyworkstation_device:
category:
- coin_cell_workstation
class:
action_value_mappings:
auto-change_hole_sheet_to_2:
feedback: {}
goal: {}
goal_default:
hole: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
hole:
type: object
required:
- hole
type: object
result: {}
required:
- goal
title: change_hole_sheet_to_2参数
type: object
type: UniLabJsonCommandAsync
auto-fill_plate:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: fill_plate参数
type: object
type: UniLabJsonCommandAsync
auto-fun_wuliao_test:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
type: boolean
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: {}
goal_default:
assembly_pressure: 4200
assembly_type: 7
battery_clean_ignore: false
battery_pressure_mode: true
dual_drop_first_volume: 25
dual_drop_mode: false
dual_drop_start_timing: false
dual_drop_suction_timing: false
elec_num: null
elec_use_num: null
elec_vol: 50
file_path: /Users/sml/work
fujipian_juzhendianwei: 0
fujipian_panshu: 0
gemo_juzhendianwei: 0
gemopanshu: 0
lvbodian: true
qiangtou_juzhendianwei: 0
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 简化版电池组装函数,整合了参数设置和双滴模式
properties:
feedback: {}
goal:
properties:
assembly_pressure:
default: 4200
description: 电池压制力(N)
type: integer
assembly_type:
default: 7
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫)
type: integer
battery_clean_ignore:
default: false
description: 是否忽略电池清洁步骤
type: boolean
battery_pressure_mode:
default: true
description: 是否启用压力模式
type: boolean
dual_drop_first_volume:
default: 25
description: 二次滴液第一次排液体积(μL)
type: integer
dual_drop_mode:
default: false
description: 电解液添加模式(false=单次滴液, true=二次滴液)
type: boolean
dual_drop_start_timing:
default: false
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
type: boolean
dual_drop_suction_timing:
default: false
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
type: boolean
elec_num:
description: 电解液瓶数
type: string
elec_use_num:
description: 每瓶电解液组装电池数
type: string
elec_vol:
default: 50
description: 电解液吸液量(μL)
type: integer
file_path:
default: /Users/sml/work
description: 实验记录保存路径
type: string
fujipian_juzhendianwei:
default: 0
description: 负极片矩阵点位。盘位置从1开始计数有效范围1-8, 13-20 (写入值比实际位置少1例如写0取盘位1写1取盘位2)
type: integer
fujipian_panshu:
default: 0
description: 负极片盘数
type: integer
gemo_juzhendianwei:
default: 0
description: 隔膜矩阵点位。盘位置从1开始计数有效范围1-8, 13-20 (写入值比实际位置少1例如写0取盘位1写1取盘位2)
type: integer
gemopanshu:
default: 0
description: 隔膜盘数
type: integer
lvbodian:
default: true
description: 是否使用铝箔垫片
type: boolean
qiangtou_juzhendianwei:
default: 0
description: 枪头盒矩阵点位。盘位置从1开始计数有效范围1-32, 64-96 (写入值比实际位置少1例如写0取盘位1写1取盘位2)
type: integer
required:
- elec_num
- elec_use_num
type: object
result:
type: object
required:
- goal
title: func_allpack_cmd_simp参数
type: object
type: UniLabJsonCommand
auto-func_get_csv_export_status:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: func_get_csv_export_status参数
type: object
type: UniLabJsonCommand
auto-func_pack_device_auto:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: func_pack_device_auto参数
type: object
type: UniLabJsonCommand
auto-func_pack_device_init:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: func_pack_device_init参数
type: object
type: UniLabJsonCommand
auto-func_pack_device_init_auto_start_combined:
feedback: {}
goal: {}
goal_default:
material_search_enable: false
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
properties:
feedback: {}
goal:
properties:
material_search_enable:
default: false
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
type: boolean
required: []
type: object
result:
type: boolean
required:
- goal
title: func_pack_device_init_auto_start_combined参数
type: object
type: UniLabJsonCommand
auto-func_pack_device_start:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: func_pack_device_start参数
type: object
type: UniLabJsonCommand
auto-func_pack_device_stop:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
type: boolean
required:
- goal
title: func_pack_device_stop参数
type: object
type: UniLabJsonCommand
auto-func_pack_get_msg_cmd:
feedback: {}
goal: {}
goal_default:
file_path: D:\coin_cell_data
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
file_path:
default: D:\coin_cell_data
type: string
required: []
type: object
result:
type: boolean
required:
- goal
title: func_pack_get_msg_cmd参数
type: object
type: UniLabJsonCommand
auto-func_pack_send_bottle_num:
feedback: {}
goal: {}
goal_default:
bottle_num: null
handles:
input:
- data_key: bottle_num
data_source: workflow
data_type: integer
handler_key: bottle_count
io_type: source
label: 配液瓶数
required: true
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
bottle_num:
type: string
required:
- bottle_num
type: object
result: {}
required:
- goal
title: func_pack_send_bottle_num参数
type: object
type: UniLabJsonCommand
auto-func_pack_send_finished_cmd:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
type: boolean
required:
- goal
title: func_pack_send_finished_cmd参数
type: object
type: UniLabJsonCommand
auto-func_pack_send_msg_cmd:
feedback: {}
goal: {}
goal_default:
assembly_pressure: null
assembly_type: null
elec_use_num: null
elec_vol: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
assembly_pressure:
type: string
assembly_type:
type: string
elec_use_num:
type: string
elec_vol:
type: string
required:
- elec_use_num
- elec_vol
- assembly_type
- assembly_pressure
type: object
result:
type: boolean
required:
- goal
title: func_pack_send_msg_cmd参数
type: object
type: UniLabJsonCommand
auto-func_read_data_and_output:
feedback: {}
goal: {}
goal_default:
file_path: /Users/sml/work
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
file_path:
default: /Users/sml/work
type: string
required: []
type: object
result: {}
required:
- goal
title: func_read_data_and_output参数
type: object
type: UniLabJsonCommand
auto-func_sendbottle_allpack_multi:
feedback: {}
goal: {}
goal_default:
assembly_pressure: 4200
assembly_type: 7
battery_clean_ignore: false
battery_pressure_mode: true
dual_drop_first_volume: 25
dual_drop_mode: false
dual_drop_start_timing: false
dual_drop_suction_timing: false
elec_num: null
elec_use_num: null
elec_vol: 50
file_path: /Users/sml/work
fujipian_juzhendianwei: 0
fujipian_panshu: 0
gemo_juzhendianwei: 0
gemopanshu: 0
lvbodian: true
qiangtou_juzhendianwei: 0
handles:
input:
- data_key: elec_num
data_source: workflow
data_type: integer
handler_key: bottle_count
io_type: source
label: 配液瓶数
required: true
placeholder_keys: {}
result: {}
schema:
description: 发送瓶数+简化组装函数(适用于第二批次及后续批次),合并了发送瓶数和简化组装流程
properties:
feedback: {}
goal:
properties:
assembly_pressure:
default: 4200
description: 电池压制力(N)
type: integer
assembly_type:
default: 7
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫)
type: integer
battery_clean_ignore:
default: false
description: 是否忽略电池清洁步骤
type: boolean
battery_pressure_mode:
default: true
description: 是否启用压力模式
type: boolean
dual_drop_first_volume:
default: 25
description: 二次滴液第一次排液体积(μL)
type: integer
dual_drop_mode:
default: false
description: 电解液添加模式(false=单次滴液, true=二次滴液)
type: boolean
dual_drop_start_timing:
default: false
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
type: boolean
dual_drop_suction_timing:
default: false
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
type: boolean
elec_num:
description: 电解液瓶数如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数
type: string
elec_use_num:
description: 每瓶电解液组装电池数
type: string
elec_vol:
default: 50
description: 电解液吸液量(μL)
type: integer
file_path:
default: /Users/sml/work
description: 实验记录保存路径
type: string
fujipian_juzhendianwei:
default: 0
description: 负极片矩阵点位。盘位置从1开始计数有效范围1-8, 13-20 (写入值比实际位置少1例如写0取盘位1写1取盘位2)
type: integer
fujipian_panshu:
default: 0
description: 负极片盘数
type: integer
gemo_juzhendianwei:
default: 0
description: 隔膜矩阵点位。盘位置从1开始计数有效范围1-8, 13-20 (写入值比实际位置少1例如写0取盘位1写1取盘位2)
type: integer
gemopanshu:
default: 0
description: 隔膜盘数
type: integer
lvbodian:
default: true
description: 是否使用铝箔垫片
type: boolean
qiangtou_juzhendianwei:
default: 0
description: 枪头盒矩阵点位。盘位置从1开始计数有效范围1-32, 64-96 (写入值比实际位置少1例如写0取盘位1写1取盘位2)
type: integer
required:
- elec_num
- elec_use_num
type: object
result:
type: object
required:
- goal
title: func_sendbottle_allpack_multi参数
type: object
type: UniLabJsonCommand
auto-func_stop_read_data:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: func_stop_read_data参数
type: object
type: UniLabJsonCommand
auto-modify_deck_name:
feedback: {}
goal: {}
goal_default:
resource_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
resource_name:
type: string
required:
- resource_name
type: object
result: {}
required:
- goal
title: modify_deck_name参数
type: object
type: UniLabJsonCommand
auto-qiming_coin_cell_code:
feedback: {}
goal: {}
goal_default:
battery_clean_ignore: false
battery_pressure: 4000
battery_pressure_mode: true
fujipian_juzhendianwei: 0
fujipian_panshu: null
gemo_juzhendianwei: 0
gemopanshu: 0
lvbodian: true
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
battery_clean_ignore:
default: false
type: boolean
battery_pressure:
default: 4000
type: integer
battery_pressure_mode:
default: true
type: boolean
fujipian_juzhendianwei:
default: 0
type: integer
fujipian_panshu:
type: integer
gemo_juzhendianwei:
default: 0
type: integer
gemopanshu:
default: 0
type: integer
lvbodian:
default: true
type: boolean
required:
- fujipian_panshu
type: object
result:
type: boolean
required:
- goal
title: qiming_coin_cell_code参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
status_types:
data_assembly_coin_cell_num: int
data_assembly_pressure: int
data_assembly_time: float
data_axis_x_pos: float
data_axis_y_pos: float
data_axis_z_pos: float
data_coin_cell_code: str
data_coin_num: int
data_electrolyte_code: str
data_electrolyte_volume: int
data_glove_box_o2_content: float
data_glove_box_pressure: float
data_glove_box_water_content: float
data_open_circuit_voltage: float
data_pole_weight: float
request_rec_msg_status: bool
request_send_msg_status: bool
sys_mode: str
sys_status: str
type: python
config_info: []
description: ''
handles: []
icon: koudian.webp
init_param_schema:
config:
properties:
address:
default: 172.16.28.102
type: string
config:
type: object
debug_mode:
default: false
type: boolean
deck:
type: string
port:
default: '502'
type: string
required: []
type: object
data:
properties:
data_assembly_coin_cell_num:
type: integer
data_assembly_pressure:
type: integer
data_assembly_time:
type: number
data_axis_x_pos:
type: number
data_axis_y_pos:
type: number
data_axis_z_pos:
type: number
data_coin_cell_code:
type: string
data_coin_num:
type: integer
data_electrolyte_code:
type: string
data_electrolyte_volume:
type: integer
data_glove_box_o2_content:
type: number
data_glove_box_pressure:
type: number
data_glove_box_water_content:
type: number
data_open_circuit_voltage:
type: number
data_pole_weight:
type: number
request_rec_msg_status:
type: boolean
request_send_msg_status:
type: boolean
sys_mode:
type: string
sys_status:
type: string
required:
- data_assembly_coin_cell_num
- data_assembly_pressure
- data_assembly_time
- 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
type: object
version: 1.0.0

View File

@@ -50,26 +50,25 @@ gas_source.mock:
goal: {}
goal_default: {}
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -82,26 +81,25 @@ gas_source.mock:
goal: {}
goal_default: {}
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -116,32 +114,31 @@ gas_source.mock:
goal_default:
string: ''
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: StrSingleInput_Feedback
type: object
goal:
additionalProperties: false
properties:
string:
type: string
required:
- string
title: StrSingleInput_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: StrSingleInput_Result
type: object
required:
@@ -232,26 +229,25 @@ vacuum_pump.mock:
goal: {}
goal_default: {}
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -264,26 +260,25 @@ vacuum_pump.mock:
goal: {}
goal_default: {}
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -298,32 +293,31 @@ vacuum_pump.mock:
goal_default:
string: ''
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: StrSingleInput_Feedback
type: object
goal:
additionalProperties: false
properties:
string:
type: string
required:
- string
title: StrSingleInput_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: StrSingleInput_Result
type: object
required:

View File

@@ -5,7 +5,7 @@ hotel.thermo_orbitor_rs2_hotel:
action_value_mappings: {}
module: unilabos.devices.resource_container.container:HotelContainer
status_types:
rotation: String
rotation: ''
type: python
config_info: []
description: Thermo Orbitor RS2 Hotel容器设备用于实验室样品的存储和管理。该设备通过HotelContainer类实现容器的旋转控制和状态监控主要用于存储实验样品、试剂瓶或其他实验器具支持旋转功能以便于样品的自动化存取。适用于需要有序存储和快速访问大量样品的实验室自动化场景。

View File

@@ -22,7 +22,8 @@ xyz_stepper_controller:
required:
- degrees
type: object
result: {}
result:
type: integer
required:
- goal
title: degrees_to_steps参数
@@ -47,7 +48,8 @@ xyz_stepper_controller:
required:
- axis
type: object
result: {}
result:
type: boolean
required:
- goal
title: emergency_stop参数
@@ -72,7 +74,10 @@ xyz_stepper_controller:
type: boolean
required: []
type: object
result: {}
result:
additionalProperties:
type: boolean
type: object
required:
- goal
title: enable_all_axes参数
@@ -101,7 +106,8 @@ xyz_stepper_controller:
required:
- axis
type: object
result: {}
result:
type: boolean
required:
- goal
title: enable_motor参数
@@ -122,7 +128,10 @@ xyz_stepper_controller:
properties: {}
required: []
type: object
result: {}
result:
additionalProperties:
type: boolean
type: object
required:
- goal
title: home_all_axes参数
@@ -147,7 +156,8 @@ xyz_stepper_controller:
required:
- axis
type: object
result: {}
result:
type: boolean
required:
- goal
title: home_axis参数
@@ -188,7 +198,8 @@ xyz_stepper_controller:
- axis
- position
type: object
result: {}
result:
type: boolean
required:
- goal
title: move_to_position参数
@@ -229,7 +240,8 @@ xyz_stepper_controller:
- axis
- degrees
type: object
result: {}
result:
type: boolean
required:
- goal
title: move_to_position_degrees参数
@@ -270,7 +282,8 @@ xyz_stepper_controller:
- axis
- revolutions
type: object
result: {}
result:
type: boolean
required:
- goal
title: move_to_position_revolutions参数
@@ -301,14 +314,17 @@ xyz_stepper_controller:
default: 5000
type: integer
x:
type: string
type: integer
y:
type: string
type: integer
z:
type: string
type: integer
required: []
type: object
result: {}
result:
additionalProperties:
type: boolean
type: object
required:
- goal
title: move_xyz参数
@@ -339,14 +355,17 @@ xyz_stepper_controller:
default: 5000
type: integer
x_deg:
type: string
type: number
y_deg:
type: string
type: number
z_deg:
type: string
type: number
required: []
type: object
result: {}
result:
additionalProperties:
type: boolean
type: object
required:
- goal
title: move_xyz_degrees参数
@@ -377,14 +396,17 @@ xyz_stepper_controller:
default: 5000
type: integer
x_rev:
type: string
type: number
y_rev:
type: string
type: number
z_rev:
type: string
type: number
required: []
type: object
result: {}
result:
additionalProperties:
type: boolean
type: object
required:
- goal
title: move_xyz_revolutions参数
@@ -409,7 +431,8 @@ xyz_stepper_controller:
required:
- revolutions
type: object
result: {}
result:
type: integer
required:
- goal
title: revolutions_to_steps参数
@@ -442,7 +465,8 @@ xyz_stepper_controller:
- axis
- speed
type: object
result: {}
result:
type: boolean
required:
- goal
title: set_speed_mode参数
@@ -467,7 +491,8 @@ xyz_stepper_controller:
required:
- steps
type: object
result: {}
result:
type: number
required:
- goal
title: steps_to_degrees参数
@@ -492,7 +517,8 @@ xyz_stepper_controller:
required:
- steps
type: object
result: {}
result:
type: number
required:
- goal
title: steps_to_revolutions参数
@@ -513,7 +539,10 @@ xyz_stepper_controller:
properties: {}
required: []
type: object
result: {}
result:
additionalProperties:
type: boolean
type: object
required:
- goal
title: stop_all_axes参数
@@ -542,7 +571,8 @@ xyz_stepper_controller:
required:
- axis
type: object
result: {}
result:
type: boolean
required:
- goal
title: wait_for_completion参数
@@ -550,8 +580,7 @@ xyz_stepper_controller:
type: UniLabJsonCommand
module: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:XYZStepperController
status_types:
all_positions: dict
motor_status: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:MotorPosition
all_positions: Dict[MotorAxis, MotorPosition]
type: python
config_info: []
description: 新XYZ控制器
@@ -574,12 +603,10 @@ xyz_stepper_controller:
data:
properties:
all_positions:
type: object
motor_status:
additionalProperties:
type: object
type: object
required:
- motor_status
- all_positions
type: object
registry_type: device
version: 1.0.0

File diff suppressed because it is too large Load Diff

View File

@@ -5,31 +5,6 @@ neware_battery_test_system:
- battery_test
class:
action_value_mappings:
auto-post_init:
feedback: {}
goal: {}
goal_default:
ros_node: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
ros_node:
type: string
required:
- ros_node
type: object
result: {}
required:
- goal
title: post_init参数
type: object
type: UniLabJsonCommand
auto-print_status_summary:
feedback: {}
goal: {}
@@ -66,7 +41,8 @@ neware_battery_test_system:
properties: {}
required: []
type: object
result: {}
result:
type: boolean
required:
- goal
title: test_connection参数
@@ -77,9 +53,8 @@ neware_battery_test_system:
goal: {}
goal_default: {}
handles: {}
result:
return_info: return_info
success: success
placeholder_keys: {}
result: {}
schema:
description: 调试方法:显示所有资源的实际名称
properties:
@@ -89,19 +64,10 @@ neware_battery_test_system:
required: []
type: object
result:
properties:
return_info:
description: 资源调试信息
type: string
success:
description: 是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
title: debug_resource_names参数
type: object
type: UniLabJsonCommand
export_status_json:
@@ -111,9 +77,8 @@ neware_battery_test_system:
goal_default:
filepath: bts_status.json
handles: {}
result:
return_info: return_info
success: success
placeholder_keys: {}
result: {}
schema:
description: 导出当前状态数据到JSON文件
properties:
@@ -127,19 +92,10 @@ neware_battery_test_system:
required: []
type: object
result:
properties:
return_info:
description: 导出操作结果信息
type: string
success:
description: 导出是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
title: export_status_json参数
type: object
type: UniLabJsonCommand
get_device_summary:
@@ -181,10 +137,8 @@ neware_battery_test_system:
goal_default:
plate_num: null
handles: {}
result:
plate_data: plate_data
return_info: return_info
success: success
placeholder_keys: {}
result: {}
schema:
description: 获取指定盘或所有盘的状态信息
properties:
@@ -193,29 +147,14 @@ neware_battery_test_system:
properties:
plate_num:
description: 盘号 (1 或 2)如果为null则返回所有盘的状态
maximum: 2
minimum: 1
type: integer
required: []
type: object
result:
properties:
plate_data:
description: 盘状态数据(单盘或所有盘)
type: object
return_info:
description: 操作结果信息
type: string
success:
description: 查询是否成功
type: boolean
required:
- return_info
- success
- plate_data
type: object
required:
- goal
title: get_plate_status参数
type: object
type: UniLabJsonCommand
print_status_summary_action:
@@ -223,9 +162,8 @@ neware_battery_test_system:
goal: {}
goal_default: {}
handles: {}
result:
return_info: return_info
success: success
placeholder_keys: {}
result: {}
schema:
description: 打印通道状态摘要信息到控制台
properties:
@@ -235,28 +173,21 @@ neware_battery_test_system:
required: []
type: object
result:
properties:
return_info:
description: 打印操作结果信息
type: string
success:
description: 打印是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
title: print_status_summary_action参数
type: object
type: UniLabJsonCommand
query_plate_action:
feedback: {}
goal:
string: plate_id
plate_id: plate_id
string: string
goal_default:
string: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
@@ -264,27 +195,23 @@ neware_battery_test_system:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: StrSingleInput_Feedback
type: object
goal:
additionalProperties: false
properties:
string:
type: string
required:
- string
title: StrSingleInput_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: StrSingleInput_Result
type: object
required:
@@ -298,13 +225,11 @@ neware_battery_test_system:
csv_path: string
output_dir: string
goal_default:
csv_path: ''
csv_path: null
output_dir: .
handles: {}
result:
return_info: return_info
submitted_count: submitted_count
success: success
placeholder_keys: {}
result: {}
schema:
description: 从CSV文件批量提交Neware测试任务
properties:
@@ -315,31 +240,17 @@ neware_battery_test_system:
description: 输入CSV文件的绝对路径
type: string
output_dir:
default: .
description: 输出目录用于存储XML和备份文件默认当前目录
type: string
required:
- csv_path
type: object
result:
properties:
return_info:
description: 执行结果详细信息
type: string
submitted_count:
description: 成功提交的任务数量
type: integer
success:
description: 是否成功
type: boolean
total_count:
description: CSV文件中的总行数
type: integer
required:
- return_info
- success
type: object
required:
- goal
title: submit_from_csv参数
type: object
type: UniLabJsonCommand
test_connection_action:
@@ -347,9 +258,8 @@ neware_battery_test_system:
goal: {}
goal_default: {}
handles: {}
result:
return_info: return_info
success: success
placeholder_keys: {}
result: {}
schema:
description: 测试与电池测试系统的TCP连接
properties:
@@ -359,19 +269,10 @@ neware_battery_test_system:
required: []
type: object
result:
properties:
return_info:
description: 连接测试结果信息
type: string
success:
description: 连接测试是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
title: test_connection_action参数
type: object
type: UniLabJsonCommand
upload_backup_to_oss:
@@ -392,12 +293,8 @@ neware_battery_test_system:
handler_key: uploaded_files
io_type: sink
label: Uploaded Files (with standard flow info)
result:
failed_files: failed_files
return_info: return_info
success: success
total_count: total_count
uploaded_count: uploaded_count
placeholder_keys: {}
result: {}
schema:
description: 上传备份文件到阿里云OSS
properties:
@@ -417,65 +314,17 @@ neware_battery_test_system:
required: []
type: object
result:
properties:
failed_files:
description: 上传失败的文件名列表
items:
type: string
type: array
return_info:
description: 上传操作结果信息
type: string
success:
description: 上传是否成功
type: boolean
total_count:
description: 总文件数
type: integer
uploaded_count:
description: 成功上传的文件数
type: integer
uploaded_files:
description: 成功上传的文件详情列表
items:
properties:
Battery_Code:
description: 电池编码
type: string
Electrolyte_Code:
description: 电解液编码
type: string
filename:
description: 文件名
type: string
url:
description: OSS下载链接
type: string
required:
- filename
- url
- Battery_Code
- Electrolyte_Code
type: object
type: array
required:
- return_info
- success
- uploaded_count
- total_count
- failed_files
- uploaded_files
type: object
required:
- goal
title: upload_backup_to_oss参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.neware_battery_test_system.neware_battery_test_system:NewareBatteryTestSystem
status_types:
channel_status: dict
connection_info: dict
channel_status: Dict[int, Dict]
connection_info: Dict[str, str]
device_summary: dict
plate_status: dict
status: str
total_channels: int
type: python
@@ -517,23 +366,24 @@ neware_battery_test_system:
data:
properties:
channel_status:
additionalProperties:
type: object
type: object
connection_info:
additionalProperties:
type: string
type: object
device_summary:
type: object
plate_status:
type: object
status:
type: string
total_channels:
type: integer
required:
- status
- channel_status
- connection_info
- total_channels
- plate_status
- device_summary
- status
- total_channels
type: object
version: 1.0.0

View File

@@ -49,32 +49,7 @@ opcua_example:
title: load_config参数
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: string
required:
- ros_node
type: object
result: {}
required:
- goal
title: post_init参数
type: object
type: UniLabJsonCommand
auto-print_cache_stats:
auto-refresh_node_values:
feedback: {}
goal: {}
goal_default: {}
@@ -92,32 +67,7 @@ opcua_example:
result: {}
required:
- goal
title: print_cache_stats参数
type: object
type: UniLabJsonCommand
auto-read_node:
feedback: {}
goal: {}
goal_default:
node_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
node_name:
type: string
required:
- node_name
type: object
result: {}
required:
- goal
title: read_node参数
title: refresh_node_values参数
type: object
type: UniLabJsonCommand
auto-set_node_value:
@@ -149,10 +99,50 @@ opcua_example:
title: set_node_value参数
type: object
type: UniLabJsonCommand
auto-start_node_refresh:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: start_node_refresh参数
type: object
type: UniLabJsonCommand
auto-stop_node_refresh:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: stop_node_refresh参数
type: object
type: UniLabJsonCommand
module: unilabos.device_comms.opcua_client.client:OpcUaClient
status_types:
cache_stats: dict
node_value: String
status_types: {}
type: python
config_info: []
description: null
@@ -161,36 +151,22 @@ opcua_example:
init_param_schema:
config:
properties:
cache_timeout:
default: 5.0
type: number
config_path:
type: string
deck:
type: string
password:
type: string
subscription_interval:
default: 500
type: integer
refresh_interval:
default: 1.0
type: number
url:
type: string
use_subscription:
default: true
type: boolean
username:
type: string
required:
- url
type: object
data:
properties:
cache_stats:
type: object
node_value:
type: string
required:
- node_value
- cache_stats
properties: {}
required: []
type: object
version: 1.0.0

View File

@@ -80,7 +80,8 @@ opsky_ATR30007:
type: string
required: []
type: object
result: {}
result:
type: object
required:
- goal
title: run_once参数

View File

@@ -100,42 +100,41 @@ rotavap.one:
type: object
type: UniLabJsonCommand
set_timer:
feedback: {}
feedback:
status: status
goal:
command: command
goal_default:
command: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -250,9 +249,13 @@ separator.homemade:
feedback:
status: status
goal:
event: event
settling_time: settling_time
stir_speed: stir_speed
stir_time: stir_time,
stir_time: stir_time
time: time
time_spec: time_spec
vessel: vessel
goal_default:
event: ''
settling_time: ''
@@ -281,34 +284,42 @@ separator.homemade:
sample_id: ''
type: ''
handles: {}
placeholder_keys: {}
result:
message: message
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: Stir_Feedback
type: object
goal:
additionalProperties: false
properties:
event:
type: string
settling_time:
type: string
stir_speed:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
stir_time:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
time:
type: string
time_spec:
type: string
vessel:
additionalProperties: false
properties:
category:
type: string
@@ -327,16 +338,26 @@ separator.homemade:
parent:
type: string
pose:
additionalProperties: false
properties:
orientation:
additionalProperties: false
properties:
w:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
x:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
y:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
z:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
required:
- x
@@ -346,12 +367,19 @@ separator.homemade:
title: orientation
type: object
position:
additionalProperties: false
properties:
x:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
y:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
z:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
required:
- x
@@ -381,17 +409,10 @@ separator.homemade:
- data
title: vessel
type: object
required:
- vessel
- time
- event
- time_spec
- stir_time
- stir_speed
- settling_time
title: Stir_Goal
type: object
result:
additionalProperties: false
properties:
message:
type: string
@@ -399,10 +420,6 @@ separator.homemade:
type: string
success:
type: boolean
required:
- success
- message
- return_info
title: Stir_Result
type: object
required:
@@ -418,36 +435,34 @@ separator.homemade:
goal_default:
command: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:

View File

@@ -28,31 +28,6 @@ post_process_station:
title: load_config参数
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: string
required:
- ros_node
type: object
result: {}
required:
- goal
title: post_init参数
type: object
type: UniLabJsonCommand
auto-print_cache_stats:
feedback: {}
goal: {}
@@ -104,42 +79,41 @@ post_process_station:
type: object
type: UniLabJsonCommand
disconnect:
feedback: {}
feedback:
status: status
goal:
command: {}
command: command
goal_default:
command: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -149,42 +123,41 @@ post_process_station:
type: SendCmd
read_node:
feedback:
result: result
status: status
goal:
command: node_name
command: command
node_name: node_name
goal_default:
command: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -283,17 +256,19 @@ post_process_station:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: PostProcessTriggerClean_Feedback
type: object
goal:
additionalProperties: false
properties:
acetone_inner_wall_cleaning_count:
maximum: 2147483647
minimum: -2147483648
type: integer
acetone_inner_wall_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
acetone_inner_wall_cleaning_waste_time:
maximum: 2147483647
@@ -304,6 +279,8 @@ post_process_station:
minimum: -2147483648
type: integer
acetone_outer_wall_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
acetone_outer_wall_cleaning_wait_time:
maximum: 2147483647
@@ -322,6 +299,8 @@ post_process_station:
minimum: -2147483648
type: integer
acetone_stirrer_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
acetone_stirrer_cleaning_wait_time:
maximum: 2147483647
@@ -348,6 +327,8 @@ post_process_station:
minimum: -2147483648
type: integer
nmp_inner_wall_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
nmp_inner_wall_cleaning_waste_time:
maximum: 2147483647
@@ -358,6 +339,8 @@ post_process_station:
minimum: -2147483648
type: integer
nmp_outer_wall_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
nmp_outer_wall_cleaning_wait_time:
maximum: 2147483647
@@ -376,6 +359,8 @@ post_process_station:
minimum: -2147483648
type: integer
nmp_stirrer_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
nmp_stirrer_cleaning_wait_time:
maximum: 2147483647
@@ -394,6 +379,8 @@ post_process_station:
minimum: -2147483648
type: integer
water_inner_wall_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
water_inner_wall_cleaning_waste_time:
maximum: 2147483647
@@ -404,6 +391,8 @@ post_process_station:
minimum: -2147483648
type: integer
water_outer_wall_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
water_outer_wall_cleaning_wait_time:
maximum: 2147483647
@@ -422,6 +411,8 @@ post_process_station:
minimum: -2147483648
type: integer
water_stirrer_cleaning_injection:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
water_stirrer_cleaning_wait_time:
maximum: 2147483647
@@ -431,55 +422,13 @@ post_process_station:
maximum: 2147483647
minimum: -2147483648
type: integer
required:
- nmp_outer_wall_cleaning_injection
- nmp_outer_wall_cleaning_count
- nmp_outer_wall_cleaning_wait_time
- nmp_outer_wall_cleaning_waste_time
- nmp_inner_wall_cleaning_injection
- nmp_inner_wall_cleaning_count
- nmp_pump_cleaning_suction_count
- nmp_inner_wall_cleaning_waste_time
- nmp_stirrer_cleaning_injection
- nmp_stirrer_cleaning_count
- nmp_stirrer_cleaning_wait_time
- nmp_stirrer_cleaning_waste_time
- water_outer_wall_cleaning_injection
- water_outer_wall_cleaning_count
- water_outer_wall_cleaning_wait_time
- water_outer_wall_cleaning_waste_time
- water_inner_wall_cleaning_injection
- water_inner_wall_cleaning_count
- water_pump_cleaning_suction_count
- water_inner_wall_cleaning_waste_time
- water_stirrer_cleaning_injection
- water_stirrer_cleaning_count
- water_stirrer_cleaning_wait_time
- water_stirrer_cleaning_waste_time
- acetone_outer_wall_cleaning_injection
- acetone_outer_wall_cleaning_count
- acetone_outer_wall_cleaning_wait_time
- acetone_outer_wall_cleaning_waste_time
- acetone_inner_wall_cleaning_injection
- acetone_inner_wall_cleaning_count
- acetone_pump_cleaning_suction_count
- acetone_inner_wall_cleaning_waste_time
- acetone_stirrer_cleaning_injection
- acetone_stirrer_cleaning_count
- acetone_stirrer_cleaning_wait_time
- acetone_stirrer_cleaning_waste_time
- pipe_blowing_time
- injection_pump_forward_empty_suction_count
- injection_pump_reverse_empty_suction_count
- filtration_liquid_selection
title: PostProcessTriggerClean_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: PostProcessTriggerClean_Result
type: object
required:
@@ -502,11 +451,11 @@ post_process_station:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: PostProcessGrab_Feedback
type: object
goal:
additionalProperties: false
properties:
raw_tank_number:
maximum: 2147483647
@@ -516,17 +465,13 @@ post_process_station:
maximum: 2147483647
minimum: -2147483648
type: integer
required:
- reaction_tank_number
- raw_tank_number
title: PostProcessGrab_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: PostProcessGrab_Result
type: object
required:
@@ -573,13 +518,15 @@ post_process_station:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: PostProcessTriggerPostPro_Feedback
type: object
goal:
additionalProperties: false
properties:
atomization_fast_speed:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
atomization_pressure_kpa:
maximum: 2147483647
@@ -594,8 +541,12 @@ post_process_station:
minimum: -2147483648
type: integer
first_wash_water_amount:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
initial_water_amount:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
injection_pump_push_speed:
maximum: 2147483647
@@ -622,32 +573,20 @@ post_process_station:
minimum: -2147483648
type: integer
second_wash_water_amount:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
wash_slow_speed:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
required:
- atomization_fast_speed
- wash_slow_speed
- injection_pump_suction_speed
- injection_pump_push_speed
- raw_liquid_suction_count
- first_wash_water_amount
- second_wash_water_amount
- first_powder_mixing_tim
- second_powder_mixing_time
- first_powder_wash_count
- second_powder_wash_count
- initial_water_amount
- pre_filtration_mixing_time
- atomization_pressure_kpa
title: PostProcessTriggerPostPro_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: PostProcessTriggerPostPro_Result
type: object
required:
@@ -669,30 +608,26 @@ post_process_station:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -702,8 +637,7 @@ post_process_station:
type: SendCmd
module: unilabos.devices.workstation.post_process.post_process:OpcUaClient
status_types:
cache_stats: dict
node_value: String
cache_stats: Dict[str, Any]
type: python
config_info: []
description: 后处理站
@@ -718,7 +652,9 @@ post_process_station:
config_path:
type: string
deck:
type: string
anyOf:
- type: object
- type: object
password:
type: string
subscription_interval:
@@ -738,10 +674,7 @@ post_process_station:
properties:
cache_stats:
type: object
node_value:
type: string
required:
- node_value
- cache_stats
type: object
version: 1.0.0

View File

@@ -136,36 +136,36 @@ solenoid_valve:
set_valve_position:
feedback: {}
goal:
string: position
position: position
string: string
goal_default:
string: ''
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: StrSingleInput_Feedback
type: object
goal:
additionalProperties: false
properties:
string:
type: string
required:
- string
title: StrSingleInput_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: StrSingleInput_Result
type: object
required:
@@ -278,26 +278,25 @@ solenoid_valve.mock:
goal: {}
goal_default: {}
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -310,26 +309,25 @@ solenoid_valve.mock:
goal: {}
goal_default: {}
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Feedback
type: object
goal:
properties: {}
required: []
additionalProperties: true
title: EmptyIn_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
required:
- return_info
title: EmptyIn_Result
type: object
required:
@@ -422,6 +420,27 @@ syringe_pump_with_valve.runze.SY03B-T06:
title: initialize参数
type: object
type: UniLabJsonCommand
auto-list:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: list的参数schema
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: list参数
type: object
type: UniLabJsonCommand
auto-pull_plunger:
feedback: {}
goal: {}
@@ -695,7 +714,10 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal:
properties:
position:
type: string
anyOf:
- type: integer
- type: string
- type: number
required:
- position
type: object
@@ -720,7 +742,9 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal:
properties:
velocity:
type: string
anyOf:
- type: integer
- type: string
required:
- velocity
type: object
@@ -780,13 +804,13 @@ syringe_pump_with_valve.runze.SY03B-T06:
status_types:
max_velocity: float
mode: int
plunger_position: String
plunger_position: ''
position: float
status: str
valve_position: str
velocity_end: String
velocity_grade: String
velocity_init: String
velocity_end: ''
velocity_grade: ''
velocity_init: ''
type: python
config_info: []
description: 润泽精密注射泵设备,集成阀门控制的高精度流体输送系统。该设备通过串口通信控制,支持多种运行模式和精确的体积控制。具备可变速度控制、精密定位、阀门切换、实时状态监控等功能。适用于微量液体输送、精密进样、流速控制、化学反应进料等需要高精度流体操作的实验室自动化应用。
@@ -885,15 +909,15 @@ syringe_pump_with_valve.runze.SY03B-T06:
velocity_init:
type: string
required:
- status
- mode
- max_velocity
- mode
- plunger_position
- position
- status
- valve_position
- velocity_end
- velocity_grade
- velocity_init
- velocity_end
- valve_position
- position
- plunger_position
type: object
version: 1.0.0
syringe_pump_with_valve.runze.SY03B-T08:
@@ -943,6 +967,27 @@ syringe_pump_with_valve.runze.SY03B-T08:
title: initialize参数
type: object
type: UniLabJsonCommand
auto-list:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: list的参数schema
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: list参数
type: object
type: UniLabJsonCommand
auto-pull_plunger:
feedback: {}
goal: {}
@@ -1216,7 +1261,10 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal:
properties:
position:
type: string
anyOf:
- type: integer
- type: string
- type: number
required:
- position
type: object
@@ -1241,7 +1289,9 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal:
properties:
velocity:
type: string
anyOf:
- type: integer
- type: string
required:
- velocity
type: object
@@ -1301,13 +1351,13 @@ syringe_pump_with_valve.runze.SY03B-T08:
status_types:
max_velocity: float
mode: int
plunger_position: String
plunger_position: ''
position: float
status: str
valve_position: str
velocity_end: String
velocity_grade: String
velocity_init: String
velocity_end: ''
velocity_grade: ''
velocity_init: ''
type: python
config_info: []
description: 润泽精密注射泵设备,集成阀门控制的高精度流体输送系统。该设备通过串口通信控制,支持多种运行模式和精确的体积控制。具备可变速度控制、精密定位、阀门切换、实时状态监控等功能。适用于微量液体输送、精密进样、流速控制、化学反应进料等需要高精度流体操作的实验室自动化应用。
@@ -1422,14 +1472,14 @@ syringe_pump_with_valve.runze.SY03B-T08:
velocity_init:
type: string
required:
- status
- mode
- max_velocity
- mode
- plunger_position
- position
- status
- valve_position
- velocity_end
- velocity_grade
- velocity_init
- velocity_end
- valve_position
- position
- plunger_position
type: object
version: 1.0.0

View File

@@ -4,6 +4,78 @@ reaction_station.bioyond:
- reaction_station_bioyond
class:
action_value_mappings:
add_time_constraint:
feedback: {}
goal:
duration: duration
end_point: end_point
end_step_key: end_step_key
start_point: start_point
start_step_key: start_step_key
goal_default:
duration: null
end_point: 0
end_step_key: ''
start_point: 0
start_step_key: ''
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 添加时间约束 - 在两个工作流之间添加时间约束
properties:
feedback: {}
goal:
properties:
duration:
description: 时间(秒)
type: integer
end_point:
default: 0
description: 终点计时点 (Start=开始前, End=结束后)
type: integer
end_step_key:
default: ''
description: 终点步骤Key (可选, 默认为空则自动选择)
type: string
start_point:
default: 0
description: 起点计时点 (Start=开始前, End=结束后)
type: integer
start_step_key:
default: ''
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择)
type: string
required:
- duration
type: object
result: {}
required:
- goal
title: add_time_constraint参数
type: object
type: UniLabJsonCommand
auto-clear_workflows:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: clear_workflows参数
type: object
type: UniLabJsonCommand
auto-create_order:
feedback: {}
goal: {}
@@ -23,7 +95,8 @@ reaction_station.bioyond:
required:
- json_str
type: object
result: {}
result:
type: object
required:
- goal
title: create_order参数
@@ -50,7 +123,8 @@ reaction_station.bioyond:
required:
- workflow_ids
type: object
result: {}
result:
type: object
required:
- goal
title: hard_delete_merged_workflows参数
@@ -75,7 +149,8 @@ reaction_station.bioyond:
required:
- json_str
type: object
result: {}
result:
type: object
required:
- goal
title: merge_workflow_with_parameters参数
@@ -100,7 +175,8 @@ reaction_station.bioyond:
required:
- report_request
type: object
result: {}
result:
type: object
required:
- goal
title: process_temperature_cutoff_report参数
@@ -125,12 +201,47 @@ reaction_station.bioyond:
required:
- web_workflow_json
type: object
result: {}
result:
items:
additionalProperties:
type: string
type: object
type: array
required:
- goal
title: process_web_workflows参数
type: object
type: UniLabJsonCommand
auto-set_reactor_temperature:
feedback: {}
goal: {}
goal_default:
reactor_id: null
temperature: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
reactor_id:
type: integer
temperature:
type: number
required:
- reactor_id
- temperature
type: object
result:
type: string
required:
- goal
title: set_reactor_temperature参数
type: object
type: UniLabJsonCommand
auto-skip_titration_steps:
feedback: {}
goal: {}
@@ -150,12 +261,35 @@ reaction_station.bioyond:
required:
- preintake_id
type: object
result: {}
result:
type: object
required:
- goal
title: skip_titration_steps参数
type: object
type: UniLabJsonCommand
auto-sync_workflow_sequence_from_bioyond:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
type: object
required:
- goal
title: sync_workflow_sequence_from_bioyond参数
type: object
type: UniLabJsonCommand
auto-wait_for_multiple_orders_and_get_reports:
feedback: {}
goal: {}
@@ -182,12 +316,40 @@ reaction_station.bioyond:
type: integer
required: []
type: object
result: {}
result:
type: object
required:
- goal
title: wait_for_multiple_orders_and_get_reports参数
type: object
type: UniLabJsonCommand
auto-workflow_sequence:
feedback: {}
goal: {}
goal_default:
value: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
value:
items:
type: string
type: array
required:
- value
type: object
result: {}
required:
- goal
title: workflow_sequence参数
type: object
type: UniLabJsonCommand
auto-workflow_step_query:
feedback: {}
goal: {}
@@ -207,12 +369,35 @@ reaction_station.bioyond:
required:
- workflow_id
type: object
result: {}
result:
type: object
required:
- goal
title: workflow_step_query参数
type: object
type: UniLabJsonCommand
clean_all_server_workflows:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 清空服务端所有非核心工作流 (保留核心流程)
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
type: object
required:
- goal
title: clean_all_server_workflows参数
type: object
type: UniLabJsonCommand
drip_back:
feedback: {}
goal:
@@ -223,13 +408,14 @@ reaction_station.bioyond:
torque_variation: torque_variation
volume: volume
goal_default:
assign_material_name: ''
temperature: ''
time: ''
titration_type: ''
torque_variation: ''
volume: ''
assign_material_name: null
temperature: 25.0
time: '90'
titration_type: '1'
torque_variation: 2
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 滴回去
@@ -241,27 +427,27 @@ reaction_station.bioyond:
description: 物料名称(不能为空)
type: string
temperature:
default: 25.0
description: 温度设定(°C)
type: string
type: number
time:
default: '90'
description: 观察时间(分钟)
type: string
titration_type:
description: 是否滴定(1=否, 2=是)
default: '1'
description: 是否滴定(NO=否, YES=是)
type: string
torque_variation:
description: 是否观察 (1=否, 2=是)
type: string
default: 2
description: 是否观察 (NO=否, YES=是)
type: integer
volume:
description: 分液公式(μL)
description: 分液公式(mL)
type: string
required:
- volume
- assign_material_name
- time
- torque_variation
- titration_type
- temperature
- volume
type: object
result: {}
required:
@@ -274,7 +460,7 @@ reaction_station.bioyond:
goal:
batch_reports_result: batch_reports_result
goal_default:
batch_reports_result: ''
batch_reports_result: null
handles:
input:
- data_key: batch_reports_result
@@ -290,8 +476,8 @@ reaction_station.bioyond:
handler_key: ACTUALS_EXTRACTED
io_type: sink
label: Extracted Actuals
result:
return_info: return_info
placeholder_keys: {}
result: {}
schema:
description: 从批量任务完成报告中提取每个订单的实际加料量输出extracted列表。
properties:
@@ -305,13 +491,6 @@ reaction_station.bioyond:
- batch_reports_result
type: object
result:
properties:
return_info:
description: JSON字符串包含actuals数组每项含order_code, order_id, actualTargetWeigh,
actualVolume
type: string
required:
- return_info
title: extract_actuals_from_batch_reports结果
type: object
required:
@@ -329,13 +508,14 @@ reaction_station.bioyond:
torque_variation: torque_variation
volume: volume
goal_default:
assign_material_name: ''
temperature: ''
time: ''
titration_type: ''
torque_variation: ''
volume: ''
assign_material_name: BAPP
temperature: 25.0
time: '0'
titration_type: '1'
torque_variation: 1
volume: '350'
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 液体进料烧杯
@@ -344,30 +524,30 @@ reaction_station.bioyond:
goal:
properties:
assign_material_name:
default: BAPP
description: 物料名称
type: string
temperature:
default: 25.0
description: 温度设定(°C)
type: string
type: number
time:
default: '0'
description: 观察时间(分钟)
type: string
titration_type:
description: 是否滴定(1=否, 2=是)
default: '1'
description: 是否滴定(NO=否, YES=是)
type: string
torque_variation:
description: 是否观察 (1=否, 2=是)
type: string
default: 1
description: 是否观察 (NO=否, YES=是)
type: integer
volume:
description: 分液公式(μL)
default: '350'
description: 分液公式(mL)
type: string
required:
- volume
- assign_material_name
- time
- torque_variation
- titration_type
- temperature
required: []
type: object
result: {}
required:
@@ -386,13 +566,13 @@ reaction_station.bioyond:
torque_variation: torque_variation
volume: volume
goal_default:
assign_material_name: ''
solvents: ''
temperature: '25.00'
assign_material_name: null
solvents: null
temperature: 25.0
time: '360'
titration_type: '1'
torque_variation: '2'
volume: ''
torque_variation: 2
volume: null
handles:
input:
- data_key: solvents
@@ -401,9 +581,10 @@ reaction_station.bioyond:
handler_key: solvents
io_type: source
label: Solvents Data From Calculation Node
placeholder_keys: {}
result: {}
schema:
description: 液体投料-溶剂。可以直接提供volume(μL),或通过solvents对象自动从additional_solvent(mL)计算volume。
description: 液体投料-溶剂。可以直接提供volume(mL),或通过solvents对象自动从additional_solvent(mL)计算volume。
properties:
feedback: {}
goal:
@@ -415,23 +596,23 @@ reaction_station.bioyond:
description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume'
type: string
temperature:
default: '25.00'
default: 25.0
description: 温度设定(°C),默认25.00
type: string
type: number
time:
default: '360'
description: 观察时间(分钟),默认360
type: string
titration_type:
default: '1'
description: 是否滴定(1=否, 2=是),默认1
description: 是否滴定(NO=否, YES=是),默认NO
type: string
torque_variation:
default: '2'
description: 是否观察 (1=否, 2=是),默认2
type: string
default: 2
description: 是否观察 (NO=否, YES=是),默认YES
type: integer
volume:
description: 分液量(μL)。可直接提供,或通过solvents参数自动计算
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算
type: string
required:
- assign_material_name
@@ -455,15 +636,15 @@ reaction_station.bioyond:
volume_formula: volume_formula
x_value: x_value
goal_default:
assign_material_name: ''
extracted_actuals: ''
feeding_order_data: ''
temperature: '25.00'
assign_material_name: null
extracted_actuals: null
feeding_order_data: null
temperature: 25.0
time: '90'
titration_type: '2'
torque_variation: '2'
volume_formula: ''
x_value: ''
torque_variation: 2
volume_formula: null
x_value: null
handles:
input:
- data_key: extracted_actuals
@@ -478,6 +659,7 @@ reaction_station.bioyond:
handler_key: feeding_order
io_type: source
label: Feeding Order Data From Calculation Node
placeholder_keys: {}
result: {}
schema:
description: 液体进料(滴定)。支持两种模式:1)直接提供volume_formula;2)自动计算-提供x_value+feeding_order_data+extracted_actuals,系统自动生成公式"1000*(m二酐-x)*V二酐滴定/m二酐滴定"
@@ -496,23 +678,23 @@ reaction_station.bioyond:
{"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}'
type: string
temperature:
default: '25.00'
default: 25.0
description: 温度设定(°C),默认25.00
type: string
type: number
time:
default: '90'
description: 观察时间(分钟),默认90
type: string
titration_type:
default: '2'
description: 是否滴定(1=否, 2=是),默认2
description: 是否滴定(NO=否, YES=是),默认YES
type: string
torque_variation:
default: '2'
description: 是否观察 (1=否, 2=是),默认2
type: string
default: 2
description: 是否观察 (NO=否, YES=是),默认YES
type: integer
volume_formula:
description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
type: string
x_value:
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
@@ -536,13 +718,14 @@ reaction_station.bioyond:
torque_variation: torque_variation
volume_formula: volume_formula
goal_default:
assign_material_name: ''
temperature: ''
time: ''
titration_type: ''
torque_variation: ''
volume_formula: ''
assign_material_name: null
temperature: 25.0
time: '0'
titration_type: '1'
torque_variation: 1
volume_formula: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 液体进料小瓶(非滴定)
@@ -554,27 +737,27 @@ reaction_station.bioyond:
description: 物料名称
type: string
temperature:
default: 25.0
description: 温度设定(°C)
type: string
type: number
time:
default: '0'
description: 观察时间(分钟)
type: string
titration_type:
description: 是否滴定(1=否, 2=是)
default: '1'
description: 是否滴定(NO=否, YES=是)
type: string
torque_variation:
description: 是否观察 (1=否, 2=是)
type: string
default: 1
description: 是否观察 (NO=否, YES=是)
type: integer
volume_formula:
description: 分液公式(μL)
description: 分液公式(mL)
type: string
required:
- volume_formula
- assign_material_name
- time
- torque_variation
- titration_type
- temperature
type: object
result: {}
required:
@@ -588,9 +771,10 @@ reaction_station.bioyond:
task_name: task_name
workflow_name: workflow_name
goal_default:
task_name: ''
workflow_name: ''
task_name: null
workflow_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 处理并执行工作流
@@ -608,7 +792,8 @@ reaction_station.bioyond:
- workflow_name
- task_name
type: object
result: {}
result:
type: object
required:
- goal
title: process_and_execute_workflow参数
@@ -621,10 +806,11 @@ reaction_station.bioyond:
cutoff: cutoff
temperature: temperature
goal_default:
assign_material_name: ''
cutoff: ''
temperature: ''
assign_material_name: null
cutoff: '900000'
temperature: -10.0
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 反应器放入 - 将反应器放入工作站,配置物料名称、粘度上限和温度参数
@@ -636,14 +822,14 @@ reaction_station.bioyond:
description: 物料名称
type: string
cutoff:
default: '900000'
description: 粘度上限
type: string
temperature:
default: -10.0
description: 温度设定(°C)
type: string
type: number
required:
- cutoff
- temperature
- assign_material_name
type: object
result: {}
@@ -657,6 +843,7 @@ reaction_station.bioyond:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 反应器取出 - 从工作站中取出反应器,无需参数的简单操作
@@ -666,20 +853,35 @@ reaction_station.bioyond:
properties: {}
required: []
type: object
result:
properties:
code:
description: 操作结果代码(1表示成功,0表示失败)
type: integer
return_info:
description: 操作结果详细信息
type: string
type: object
result: {}
required:
- goal
title: reactor_taken_out参数
type: object
type: UniLabJsonCommand
scheduler_start:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 启动调度器 - 启动Bioyond工作站的任务调度器开始执行队列中的任务
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
title: scheduler_start结果
type: object
required:
- goal
title: scheduler_start参数
type: object
type: UniLabJsonCommand
solid_feeding_vials:
feedback: {}
goal:
@@ -689,12 +891,13 @@ reaction_station.bioyond:
time: time
torque_variation: torque_variation
goal_default:
assign_material_name: ''
material_id: ''
temperature: ''
time: ''
torque_variation: ''
assign_material_name: null
material_id: null
temperature: 25.0
time: '0'
torque_variation: 1
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 固体进料小瓶 - 通过小瓶向反应器中添加固体物料,支持多种粉末类型(盐、面粉、BTDA)
@@ -706,23 +909,22 @@ reaction_station.bioyond:
description: 物料名称(用于获取试剂瓶位ID)
type: string
material_id:
description: 粉末类型ID1=盐21分钟2=面粉27分钟3=BTDA38分钟
description: 粉末类型IDSalt=盐21分钟Flour=面粉27分钟BTDA=BTDA38分钟
type: string
temperature:
default: 25.0
description: 温度设定(°C)
type: string
type: number
time:
default: '0'
description: 观察时间(分钟)
type: string
torque_variation:
description: 是否观察 (1=否, 2=是)
type: string
default: 1
description: 是否观察 (NO=否, YES=是)
type: integer
required:
- assign_material_name
- material_id
- time
- torque_variation
- temperature
type: object
result: {}
required:
@@ -730,10 +932,10 @@ reaction_station.bioyond:
title: solid_feeding_vials参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation
module: unilabos.devices.workstation.bioyond_studio.reaction_station.reaction_station:BioyondReactionStation
protocol_type: []
status_types:
workflow_sequence: String
workflow_sequence: str
type: python
config_info: []
description: Bioyond反应站
@@ -753,9 +955,7 @@ reaction_station.bioyond:
data:
properties:
workflow_sequence:
items:
type: string
type: array
type: string
required:
- workflow_sequence
type: object
@@ -791,7 +991,7 @@ reaction_station.reactor:
title: update_metrics参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor
module: unilabos.devices.workstation.bioyond_studio.reaction_station.reaction_station:BioyondReactor
status_types: {}
type: python
config_info: []

View File

@@ -37,42 +37,41 @@ agv.SEER:
type: object
type: UniLabJsonCommand
send_nav_task:
feedback: {}
feedback:
status: status
goal:
command: command
goal_default:
command: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:

View File

@@ -122,31 +122,6 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
title: moveit_task参数
type: object
type: UniLabJsonCommand
auto-post_init:
feedback: {}
goal: {}
goal_default:
ros_node: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: post_init的参数schema
properties:
feedback: {}
goal:
properties:
ros_node:
type: object
required:
- ros_node
type: object
result: {}
required:
- goal
title: post_init参数
type: object
type: UniLabJsonCommand
auto-resource_manager:
feedback: {}
goal: {}
@@ -199,83 +174,99 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: UniLabJsonCommand
pick_and_place:
feedback: {}
goal:
command: command
goal: {}
goal_default:
command: ''
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
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
description: pick_and_place 显式参数UniLabJsonCommand
properties:
feedback:
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
feedback: {}
goal:
properties:
command:
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:
- command
title: SendCmd_Goal
type: object
result:
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
- option
- move_group
- status
type: object
result: {}
required:
- goal
title: SendCmd
title: pick_and_place参数
type: object
type: SendCmd
type: UniLabJsonCommand
set_position:
feedback: {}
feedback:
status: status
goal:
command: command
goal_default:
command: ''
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -284,41 +275,41 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: object
type: SendCmd
set_status:
feedback: {}
feedback:
status: status
goal:
command: command
goal_default:
command: ''
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -455,42 +446,41 @@ robotic_arm.UR:
type: object
type: UniLabJsonCommand
move_pos_task:
feedback: {}
feedback:
status: status
goal:
command: command
goal_default:
command: ''
handles: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -532,8 +522,8 @@ robotic_arm.UR:
type: string
required:
- arm_pose
- gripper_pose
- arm_status
- gripper_pose
- gripper_status
type: object
version: 1.0.0
@@ -726,41 +716,41 @@ robotic_arm.elite:
type: object
type: UniLabJsonCommand
modbus_task_cmd:
feedback: {}
feedback:
status: status
goal:
command: command
goal_default:
command: ''
handles: {}
result: {}
placeholder_keys: {}
result:
return_info: return_info
success: success
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -770,8 +760,8 @@ robotic_arm.elite:
type: SendCmd
module: unilabos.devices.arm.elite_robot:EliteRobot
status_types:
actual_joint_positions: String
arm_pose: String
actual_joint_positions: ''
arm_pose: list[float]
type: python
config_info: []
description: Elite robot arm
@@ -797,8 +787,8 @@ robotic_arm.elite:
type: number
type: array
required:
- arm_pose
- actual_joint_positions
- arm_pose
type: object
model:
mesh: elite_robot

View File

@@ -114,11 +114,12 @@ gripper.misumi_rz:
goal:
properties:
data:
type: string
type: object
required:
- data
type: object
result: {}
result:
type: object
required:
- goal
title: modbus_crc参数
@@ -398,30 +399,26 @@ gripper.misumi_rz:
description: ''
properties:
feedback:
additionalProperties: false
properties:
status:
type: string
required:
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
required:
- command
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: SendCmd_Result
type: object
required:
@@ -504,71 +501,82 @@ gripper.mock:
type: UniLabJsonCommand
push_to:
feedback:
effort: torque
effort: effort
position: position
reached_goal: reached_goal
stalled: stalled
goal:
command.max_effort: torque
command.position: position
command: command
position: position
torque: torque
velocity: velocity
goal_default:
command:
max_effort: 0.0
position: 0.0
handles: {}
placeholder_keys: {}
result:
effort: torque
effort: effort
position: position
reached_goal: reached_goal
stalled: stalled
schema:
description: ''
properties:
feedback:
additionalProperties: false
properties:
effort:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
position:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
reached_goal:
type: boolean
stalled:
type: boolean
required:
- position
- effort
- stalled
- reached_goal
title: GripperCommand_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
additionalProperties: false
properties:
max_effort:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
position:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
required:
- position
- max_effort
title: command
type: object
required:
- command
title: GripperCommand_Goal
type: object
result:
additionalProperties: false
properties:
effort:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
position:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
reached_goal:
type: boolean
stalled:
type: boolean
required:
- position
- effort
- stalled
- reached_goal
title: GripperCommand_Result
type: object
required:
@@ -604,8 +612,8 @@ gripper.mock:
type: number
required:
- position
- velocity
- torque
- status
- torque
- velocity
type: object
version: 1.0.0

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