Compare commits

...

20 Commits

Author SHA1 Message Date
Andy6M
99ee27bfc2 Revert "Revert "fix(neware): add coin_cell_code input handle to battery_transfer_confirm""
This reverts commit 201b1064d7.
2026-04-22 18:18:24 +08:00
Andy6M
e8f54d50f9 fix(neware): remove output_dir from submit_auto_export_excel input handles
Made-with: Cursor
2026-04-22 18:13:45 +08:00
Andy6M
201b1064d7 Revert "fix(neware): add coin_cell_code input handle to battery_transfer_confirm"
This reverts commit 2ebe35e70e.
2026-04-22 18:13:32 +08:00
Andy6M
2ebe35e70e fix(neware): add coin_cell_code input handle to battery_transfer_confirm
Made-with: Cursor
2026-04-22 18:06:37 +08:00
Andy6M
717f236332 feat(neware): submit_auto_export_excel add manual backup path and electrolyte_code
- Add output_dir param, backup dir derived from user input (xml_dir/backup_dir auto-created)
- Add electrolyte_code param, backup file name format: coin_cell_code-electrolyte_code-devid-subdevid-chlid
- manual_confirm return value adds electrolyte_code field for downstream passthrough
- YAML: manual_confirm output handles add electrolyte_code
- YAML: submit_auto_export_excel goal/schema/goal_default/handles add output_dir and electrolyte_code
- YAML: battery_transfer_confirm output changed to empty list

Made-with: Cursor
2026-04-22 17:29:28 +08:00
Xie Qiming
79c0815b70 fix(neware): 修复 submit_auto_export_excel 因 resource=[] 导致 0 下发 + filetype kwarg
问题:
- 日志中 submit_auto_export_excel 收到 resource=[](工作流本身不传成品电池资源,
  电池由人工搬运),原代码 n = len(resource) = 0 → 整个循环跳过 →
  "共 0 颗电池,成功下发 0 颗"。
- neware_driver.start_test 原来不接收 filetype kwarg,导致 TypeError 阻塞下发。

修复:
1. submit_auto_export_excel 改为由 mount_resource 驱动循环长度:
   - 新签名以 mount_resource 为主,resource/pole_weight/coin_cell_code 均可选
   - 新增 coin_cell_code 入参,coin_id 优先级 coin_cell_code > resource.name > fallback
   - n==0 时提前返回并给出明确错误信息
2. manual_confirm 的返回值与 YAML handles/output 新增 coin_cell_code
   (从已解包的 assembly_data 直接取)
3. submit_auto_export_excel YAML goal/schema/goal_default/handles.input
   新增 coin_cell_code;required 中移除 resource(不再强制)
4. neware_driver.build_start_command / start_test 增加 filetype:int=1 参数,
   动态嵌入 XML backup 配置,消除 TypeError

Made-with: Cursor
2026-04-22 16:24:35 +08:00
Xie Qiming
f431d61d85 Fix neware test dispatch and manual_confirm CSV archival
- neware_driver: default backup filetype="1" so Neware BTS produces Excel
  backups out of the box (matches submit_*_export_excel semantics).
- submit_auto_export_excel: pass filetype=1 to align with function name
  and the newly default Excel backup.
- manual_confirm: prefix Channel_Name with a single quote when writing
  the integrated CSV so Excel keeps it as text (e.g. "6-10-2") instead
  of auto-coercing to a date (e.g. "2006/10/2"). The on-disk value is
  archival only and submit_auto_export_excel never reads it, so the
  live workflow is unaffected either way.
- neware yaml: declare explicit item properties for manual_confirm's
  formulations and assembly_data arrays so the orchestrator schema
  projection keeps the 7/9 upstream fields intact.

Made-with: Cursor
2026-04-22 15:21:15 +08:00
Xie Qiming
3af86a07f2 Trim manual_confirm outputs and fix resource uuid lookup
- neware manual_confirm: drop formulations/assembly_data from result and output handles (they only feed internal CSV export and should not be passed downstream); return dict no longer carries those two keys
- base_device_node.loop_find_with_uuid consumer: iterate all figured_resources instead of breaking after first attempt; raise explicit error when uuid cannot be resolved

Made-with: Cursor
2026-04-22 11:18:45 +08:00
Xie Qiming
d1713fcca1 Wire bioyond/coin-cell/neware param passing and add manual-confirm CSV export
- coin_cell_assembly: align battery_info to 9 fields (Time/open_circuit_voltage/pole_weight/assembly_time/assembly_pressure/electrolyte_volume/data_coin_type/electrolyte_code/coin_cell_code); expose assembly_data single array; rename CSV column coin_num -> data_coin_type
- coin_cell_workstation.yaml: add assembly_data_output handle for auto-func_sendbottle_allpack_multi
- neware manual_confirm: accept formulations + assembly_data + csv_export_dir, unpack to parallel lists, export merged CSV to {csv_export_dir}/{date}/date_{date}.csv, output pole_weight for downstream
- neware transfer -> battery_transfer_confirm with manual_confirm node_type, timeout_seconds, assignee_user_ids
- neware test -> submit_auto_export_excel, accept pole_weight input; relabel battery_system as xml工步

Made-with: Cursor
2026-04-21 20:01:49 +08:00
Xie Qiming
52b460466d Update neware battery test system driver and registry
- Expand neware_battery_test_system.py with new actions and logic
- Update generate_xml_content.py with additional XML generation support
- Extend neware_battery_test_system.yaml registry with new action schemas
- Update OSS upload READMEs and device.json
- Add electrode_sheet.py resource fields

Made-with: Cursor
2026-04-21 17:30:56 +08:00
Xuwznln
7efccbc688 update workbench example 2026-04-21 12:03:25 +08:00
Xuwznln
dc1de44b19 update aksk desc 2026-04-21 12:03:12 +08:00
Xuwznln
4581ee1eeb print res query logs 2026-04-21 12:03:01 +08:00
Xuwznln
620cb8435f Fix skills exec error with action type 2026-04-21 12:02:40 +08:00
Xuwznln
83565038cb Fix skills exec error with action type 2026-04-21 12:01:51 +08:00
Xuwznln
01d281189a Update Skills 2026-04-21 11:58:28 +08:00
Xuwznln
db22156d77 Update Skills addr 2026-04-21 11:58:16 +08:00
Xuwznln
20342c6484 Change uni-lab. to leap-lab.
Support unit in pylabrobot
2026-04-21 11:58:02 +08:00
Xuwznln
008c355754 Support async func. 2026-04-21 11:57:48 +08:00
Xuwznln
0895252bc1 change to leap-lab backend. Support feedback interval. Reduce cocurrent lags. 2026-04-21 11:56:13 +08:00
55 changed files with 5149 additions and 489 deletions

View File

@@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{
### 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` |
| `--addr` | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
@@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
@@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
```
返回成功时包含试剂 UUID
```json
{"code": 0, "data": {"uuid": "xxx", ...}}
```
@@ -98,28 +100,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
## 试剂字段说明
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `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"` |
| 字段 | 类型 | 必填 | 说明 | 示例 |
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
| `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"` | 克 |
| `"L"` | 升 |
| `"g"` | 克 |
| `"kg"` | 千克 |
| `"瓶"` | 瓶 |
| `"瓶"` | 瓶 |
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
@@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
```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"}
{
"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"
}
]
```
@@ -160,9 +176,20 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
```
### 日期格式规则(重要)
所有日期字段(`production_date``expiry_date`**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`API 会拒绝。
### 执行与汇报
每次 API 调用后:
1. 检查返回 `code`0 = 成功)
2. 记录成功/失败数量
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
@@ -172,28 +199,29 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
## 常见试剂速查表
| 名称 | 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 |
| 名称 | CAS | 分子式 | SMILES |
| --------------------- | --------- | ---------- | ------------------------------------ |
| 水 | 7732-18-3 | H2O | O |
| 乙醇 | 64-17-5 | C2H6O | CCO |
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
| 甲醇 | 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 应根据化学知识推断或提示用户补充。

View File

@@ -1,11 +1,13 @@
---
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/实验轮次/实验状态.
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — 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/实验轮次/实验状态.
---
# 批量提交实验指南
# Uni-Lab 批量提交实验指南
通过云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
通过 Uni-Lab 云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
@@ -18,25 +20,28 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成
# 方式一Python 一行生成注意scheme 是 "Lab" 不是 "Basic"
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>
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme绝对不能用 "Basic" 替代
```
### 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` |
| `--addr` | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
# ⚠️ Auth scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
@@ -44,18 +49,19 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
按优先级搜索
**必须先用 Glob 工具搜索文件**,不要直接猜测路径
```
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
<workspace 根目录>/req_device_registry_upload.json
Glob: **/req_device_registry_upload.json
```
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
常见位置(仅供参考,以 Glob 实际结果为准):
- `<workspace>/unilabos_data/req_device_registry_upload.json`
- `<workspace>/req_device_registry_upload.json`
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
### 4. workflow_uuid目标工作流
@@ -93,7 +99,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
@@ -104,9 +110,33 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
```
返回项目列表,展示给用户选择。列出每个项目的 `uuid``name`
返回
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
```json
{
"code": 0,
"data": {
"items": [
{
"uuid": "1b3f249a-...",
"name": "bt",
"description": null,
"status": "active",
"created_at": "2026-04-09T14:31:28+08:00"
},
{
"uuid": "b6366243-...",
"name": "default",
"description": "默认项目",
"status": "active",
"created_at": "2026-03-26T11:13:36+08:00"
}
]
}
}
```
展示 `data.items[]` 中每个项目的 `name``uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
### 3. 列出可用 workflow
@@ -123,6 +153,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
```
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
- 每个 action 节点的 `node_uuid`
- 每个节点对应的设备 ID`resource_template_name`
- 每个节点的动作名(`node_template_name`
@@ -142,30 +173,30 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
```json
{
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
]
}
]
}
```
@@ -194,25 +225,25 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
### 每轮的字段
| 字段 | 类型 | 说明 |
|------|------|------|
| 字段 | 类型 | 说明 |
| -------------- | ------------- | ----------------------------------------- |
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
### datas 中每个节点
| 字段 | 类型 | 说明 |
|------|------|------|
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #4 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
| 字段 | 类型 | 说明 |
| --------------- | ------ | -------------------------------------------- |
| `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}` |
| 字段 | 类型 | 说明 |
| ---------------- | ------ | ---------------------------------------------------- |
| `container_uuid` | string | 容器 UUID |
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
---
@@ -233,6 +264,7 @@ python scripts/gen_notebook_params.py \
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
脚本会:
1. 调用 workflow detail API 获取所有 action 节点
2. 读取本地注册表,为每个节点查找对应的 action schema
3. 生成 `notebook_template.json`,包含:
@@ -270,8 +302,11 @@ python scripts/gen_notebook_params.py \
"properties": {
"goal": {
"properties": {
"asp_vols": {"type": "array", "items": {"type": "number"}},
"sources": {"type": "array"}
"asp_vols": {
"type": "array",
"items": { "type": "number" }
},
"sources": { "type": "array" }
},
"required": ["asp_vols", "sources"]
}

View File

@@ -7,7 +7,7 @@
选项:
--auth <token> Lab tokenbase64(ak:sk) 的结果,不含 "Lab " 前缀)
--base <url> API 基础 URL如 https://uni-lab.test.bohrium.com
--base <url> API 基础 URL如 https://leap-lab.test.bohrium.com
--workflow-uuid <uuid> 目标 workflow 的 UUID
--registry <path> 本地注册表文件路径(默认自动搜索)
--rounds <n> 实验轮次数(默认 1
@@ -17,7 +17,7 @@
示例:
python gen_notebook_params.py \\
--auth YTFmZDlkNGUtxxxx \\
--base https://uni-lab.test.bohrium.com \\
--base https://leap-lab.test.bohrium.com \\
--workflow-uuid abc-123-def \\
--rounds 2
"""

View File

@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
决定 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 |
| `--addr` | BASE URL |
| -------------- | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
| 其他自定义 URL | 直接使用该 URL |
#### 必备项 ③req_device_registry_upload.json设备注册表
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
**推断 working_dir**(即 `unilabos_data` 所在目录):
| 条件 | working_dir 取值 |
|------|------------------|
| 条件 | working_dir 取值 |
| -------------------- | -------------------------------------------------------- |
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
**按优先级搜索文件**
@@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config <config.py>
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 — 列出可用设备
@@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
每个 action 生成一个 JSON 文件,包含:
- `type` — 作为 API 调用的 `action_type`
- `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义)
- `goal` — goal 字段映射(含占位符 `$placeholder`
@@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
### Step 3 — 写 action-index.md
按模板为每个 action 写条目:
按模板为每个 action 写条目**必须包含 `action_type`**
```markdown
### `<action_name>`
<用途描述(一句话)>
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
- **可选参数**: `param3`, `param4`
@@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
```
描述规则:
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
-`schema.properties` 读参数列表schema 已提升为 goal 内容)
-`schema.required` 区分核心/可选参数
- 按功能分类(移液、枪头、外设等)
@@ -165,6 +151,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板,修改:
- 设备名称
- Action 数量
- 目录列表
@@ -172,42 +159,77 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot物料/设备/节点/类名)
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
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
# - #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 action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
# - #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
# 返回 workflow 完整结构data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
# 返回 res_template_uuid用于 #15 创建物料时的必填字段
# - #15 创建物料节点 POST /edge/material/node
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
# - #16 更新物料节点 PUT /edge/material/node
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
## Placeholder Slot 填写规则
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
@@ -217,13 +239,15 @@ API 模板结构:
- 列出本设备所有 Slot 字段、类型及含义
## 渐进加载策略
## 完整工作流 Checklist
```
### Step 5 — 验证
检查文件完整性:
- [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
- [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `action-index.md` 列出所有 action 并有描述
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
@@ -272,92 +296,48 @@ API 模板结构:
`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**,配合液体配方 |
| 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"}
```
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
- 单个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`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
> **特例**`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
### DeviceSlot / NodeSlot / ClassSlot
### 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"
```
- **DeviceSlot**`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
- **NodeSlot**`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
- **ClassSlot**`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
### 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 }
]
"well_name": "bottle_A1",
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
}
]
```
#### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `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` 引用物料,并附带液体配方信息
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`体积单位由上下文决定pylabrobot 内部统一 uL
- `sample_uuid` — 样品 UUID无样品传 `""`
- 与 ResourceSlot 的区别ResourceSlot 指向物料本身FormulationSlot 引用物料名并附带配方信息
### 通过 API #12 获取资源树
@@ -365,7 +345,147 @@ API 模板结构:
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name``uuid``type``parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
```json
{
"code": 0,
"data": {
"nodes": [
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
],
"edges": [...]
}
}
```
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
- `type` 区分设备(`device`)和物料(`deck``container``resource` 等)
- `parent` 为父节点名称(空字符串表示顶级)
- 填写 Slot 时根据 placeholder 类型筛选ResourceSlot 取非 device 节点DeviceSlot 取 device 节点
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
## 物料管理 API
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid``parent_uuid` 均从 **#12 资源树下载** 获取。
### API #14 — 按名称查询物料模板
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
| 参数 | 必填 | 说明 |
| ---------- | ------ | -------------------------------- |
| `lab_uuid` | **是** | 实验室 UUID从 API #1 获取) |
| `name` | **是** | 物料模板名称(如 `"container"` |
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name``resource_type``handles``config_infos` 等模板元信息。
模板不存在时返回 `code: 10002``data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
### API #15 — 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "my_custom_bottle",
"display_name": "自定义瓶子",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"type": "",
"init_param_data": {},
"schema": {},
"data": {
"liquids": [["water", 1000, "uL"]],
"max_volume": 50000
},
"plate_well_datas": {},
"plate_reagent_datas": {},
"pose": {},
"model": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
| `display_name` | 否 | string | 用户自定义 | 显示名称UI 展示用) |
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
| `type` | 否 | string | 从模板继承 | 节点类型 |
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
| `data` | 否 | object | 用户指定 | 节点数据container 见下方 data 格式 |
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
| `schema` | 否 | object | 从模板继承 | 自定义 schema不传则从模板继承 |
| `pose` | 否 | object | 用户指定 | 位姿信息 |
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
#### container 的 `data` 格式
> **体积单位统一为 uL微升**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume均为 uL。外部如果是 mL 需乘 1000 转换。
```json
{
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
"max_volume": 50000
}
```
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
- `max_volume` — 容器最大容量uL如 50 mL = 50000 uL
### API #16 — 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"display_name": "新显示名称",
"description": "新描述",
"init_param_data": {},
"data": {},
"pose": {},
"schema": {},
"extra": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
| `description` | 否 | string | 用户指定 | 更新描述 |
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
| `data` | 否 | object | 用户指定 | 更新节点数据 |
| `pose` | 否 | object | 用户指定 | 更新位姿 |
| `schema` | 否 | object | 用户指定 | 更新 schema |
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
> 只传需要更新的字段,未传的字段保持不变。
## 最终目录结构

View File

@@ -0,0 +1,251 @@
---
name: host-node
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
---
# Host Node API Skill
## 设备信息
- **device_id**: `host_node`
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
- **设备类**: `HostNode`
- **动作数**: 4`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`base64(ak:sk)``Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``host_node`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `host_node`
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `test_latency` | `UniLabJsonCommand` |
| `create_resource` | `ResourceCreateFromOuterEasy` |
| `auto-test_resource` | `UniLabJsonCommand` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | 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"` | 注册表中已注册的资源类 |
### host_node 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ----------- | ------------ | ------------------------------ |
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```

View File

@@ -0,0 +1,58 @@
# Action Index — host_node
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 资源管理
### `create_resource`
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
- **action_type**: `ResourceCreateFromOuterEasy`
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
- **占位符字段**:
- `res_id`**ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
- `device_id`**DeviceSlot**,填路径字符串如 `"/host_node"`
- `parent`**NodeSlot**,填路径字符串如 `"/workstation/deck"`
- `class_name`**ClassSlot**,填类名如 `"container"`
### `auto-test_resource`
测试资源系统,返回当前资源树和设备列表
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
- **可选参数**: `resource`, `resources`, `device`, `devices`
- **占位符字段**:
- `resource`**ResourceSlot**,单个物料节点 `{id, name, uuid}`
- `resources`**ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
- `device`**DeviceSlot**,设备路径字符串
- `devices`**DeviceSlot**,设备路径字符串
---
## 系统工具
### `test_latency`
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
- **参数**: 无(零参数调用)
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
- **占位符字段**: `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -0,0 +1,93 @@
{
"type": "ResourceCreateFromOuterEasy",
"goal": {
"res_id": "res_id",
"class_name": "class_name",
"parent": "parent",
"device_id": "device_id",
"bind_locations": "bind_locations",
"liquid_input_slot": "liquid_input_slot[]",
"liquid_type": "liquid_type[]",
"liquid_volume": "liquid_volume[]",
"slot_on_deck": "slot_on_deck"
},
"schema": {
"type": "object",
"properties": {
"res_id": {
"type": "string"
},
"device_id": {
"type": "string"
},
"class_name": {
"type": "string"
},
"parent": {
"type": "string"
},
"bind_locations": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "bind_locations",
"additionalProperties": false
},
"liquid_input_slot": {
"type": "array",
"items": {
"type": "integer"
}
},
"liquid_type": {
"type": "array",
"items": {
"type": "string"
}
},
"liquid_volume": {
"type": "array",
"items": {
"type": "number"
}
},
"slot_on_deck": {
"type": "string"
}
},
"required": [],
"_unilabos_placeholder_info": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
},
"goal_default": {},
"placeholder_keys": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
}

View File

@@ -0,0 +1,32 @@
{
"type": "UniLabJsonCommand",
"goal": {
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -0,0 +1,11 @@
{
"type": "UniLabJsonCommand",
"goal": {},
"schema": {
"type": "object",
"properties": {},
"required": []
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,255 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"resources": "resources",
"device": "device",
"devices": "devices"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"resources": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resources"
},
"type": "array"
},
"device": {
"type": "string",
"description": "device reference"
},
"devices": {
"type": "string",
"description": "device reference"
}
},
"required": [],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
}

View File

@@ -1,11 +1,13 @@
---
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结果.
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-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结果.
---
# 提交历史实验记录指南
# Uni-Lab 提交历史实验记录指南
通过云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
@@ -18,23 +20,26 @@ description: Submit historical experiment results (agent_result) to Uni-Lab note
生成 AUTH token
```bash
# ⚠️ 注意scheme 是 "Lab"Uni-Lab 专用),不是 "Basic"
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
```
输出即为 token 值,拼接为 `Authorization: Lab <token>`
输出即为 token 值,拼接为 `Authorization: Lab <token>``Lab` 是 Uni-Lab 平台 auth scheme不可替换为 `Basic`
### 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` |
| `--addr` | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
# ⚠️ Auth scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
@@ -45,6 +50,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`
如果用户不记得,可提示:
- 查看之前的对话记录中创建 notebook 时返回的 UUID
- 或通过平台页面查找对应的 notebook
@@ -54,11 +60,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,
用户需要提供实验结果数据,支持以下方式:
| 方式 | 说明 |
|------|------|
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
| 方式 | 说明 |
| --------- | ----------------------------------------------- |
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
**四项全部就绪后才可开始。**
@@ -90,7 +96,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
@@ -121,42 +127,45 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
#### 必要字段
| 字段 | 类型 | 说明 |
|------|------|------|
| 字段 | 类型 | 说明 |
| --------------- | ------------- | ------------------------------------------- |
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID从批量提交实验时获取 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
#### agent_result 内容格式
`agent_result` 接受**任意 JSON 对象**,常见格式:
**简单键值对**
```json
{
"avg_rtt_ms": 12.5,
"status": "success",
"test_count": 5
"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"}
]
"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}
]
"experiment_data": [
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
]
}
```
@@ -178,22 +187,22 @@ python scripts/prepare_agent_result.py \
[--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` |
| 参数 | 必选 | 说明 |
| ----------------- | ---------- | ----------------------------------------------- |
| `--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 | 字段直接合并到 `agent_result` 顶层 |
| `.json`list/other | 以文件名为 key 放入 `agent_result` |
| `.csv` | 以文件名(不含扩展名)为 key值为行对象数组 |
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
@@ -210,7 +219,7 @@ 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 \
--base https://leap-lab.test.bohrium.com \
--submit
```
@@ -272,4 +281,4 @@ Task Progress:
### Q: 认证方式是 Lab 还是 Api
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式`Lab` 是 Uni-Lab 平台的 auth scheme**绝不能用 `Basic` 替代**。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。

View File

@@ -0,0 +1,272 @@
---
name: virtual-workbench
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
---
# Virtual Workbench API Skill
## 设备信息
- **device_id**: `virtual_workbench`
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
- **设备类**: `VirtualWorkbench`
- **动作数**: 6`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s独占锁和 3 个加热台(每次加热 60s可并行
### 典型工作流程
1. `prepare_materials` — 生成 A1-A5 物料5 个 output handle
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
3. `start_heating` — 启动加热3 个加热台可并行)
4. `move_to_output` — 加热完成后移到输出位置 Cn
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`base64(ak:sk)``Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``virtual_workbench`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `virtual_workbench`
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `auto-prepare_materials` | `UniLabJsonCommand` |
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
| `auto-start_heating` | `UniLabJsonCommand` |
| `auto-move_to_output` | `UniLabJsonCommand` |
| `transfer` | `UniLabJsonCommandAsync` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | 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"` | 注册表中已注册的资源类 |
### virtual_workbench 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ---------------- | ------------ | -------------------- |
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```
### 典型 5 物料并发加热工作流示例
```
prepare_materials (count=5)
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
```
创建节点时,`prepare_materials` 的 5 个 output handle`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station``heating_station_output``material_number_output` 连接到对应 `start_heating``station_id_input``material_number_input`

View File

@@ -0,0 +1,76 @@
# Action Index — virtual_workbench
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 物料准备
### `auto-prepare_materials`
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
- **可选参数**: `count`(物料数量,默认 5
---
## 机械臂 & 加热台操作
### `auto-move_to_heating_station`
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
- **核心参数**: `material_number`物料编号integer
### `auto-start_heating`
启动指定加热台的加热程序可并行3 个加热台同时工作)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
### `auto-move_to_output`
将加热完成的物料从加热台移动到输出位置 Cn
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
---
## 物料转移
### `transfer`
异步转移物料到目标设备(通过 ROS 资源转移)
- **action_type**: `UniLabJsonCommandAsync`
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`
- **占位符字段**:
- `resource`**ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
- `target_device`**DeviceSlot**,目标设备路径字符串
- `mount_resource`**ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
- **占位符字段**:
- `resource`**ResourceSlot**,物料数组
- `target_device`**DeviceSlot**,目标设备路径
- `mount_resource`**ResourceSlot**,目标孔位数组
- `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -0,0 +1,270 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource",
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
},
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"resource",
"target_device",
"mount_resource",
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -0,0 +1,19 @@
{
"type": "UniLabJsonCommand",
"goal": {
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"material_number": {
"type": "integer"
}
},
"required": [
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,24 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,20 @@
{
"type": "UniLabJsonCommand",
"goal": {
"count": "count"
},
"schema": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"default": 5
}
},
"required": []
},
"goal_default": {
"count": 5
},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,24 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,255 @@
{
"type": "UniLabJsonCommandAsync",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
}
},
"required": [
"resource",
"target_device",
"mount_resource"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
}

View File

@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
**获取方式:**
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
![copy_aksk.gif](image/copy_aksk.gif)
@@ -69,7 +69,7 @@ class WSConfig:
# HTTP配置
class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
# ROS配置
class ROSConfig:
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1`
- `test``https://leap-lab.test.bohrium.com/api/v1`
- `uat``https://leap-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | -------------------------------------- | ------------ |
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | --------------------------------------- | ------------ |
| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
**预设环境地址**
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1`
### 4. ROSConfig - ROS 配置
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
```
## 配置文件使用方法
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
```python
class HTTPConfig:
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
```
**环境变量方式:**
```bash
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
```
**命令行方式(推荐):**

View File

@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
```
┌──────────────────────────────────────────────┐
│ Cloud Platform/Self-hosted Platform │
uni-lab.bohrium.com │
leap-lab.bohrium.com │
│ (Resource Management, Task Scheduling, │
│ Monitoring) │
└────────────────────┬─────────────────────────┘
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
```bash
# 测试云端连接
curl https://uni-lab.bohrium.com/api/v1/health
curl https://leap-lab.bohrium.com/api/v1/health
# 测试WebSocket
# 启动Uni-Lab后查看日志

View File

@@ -33,11 +33,11 @@
**选择合适的安装包:**
| 安装包 | 适用场景 | 包含组件 |
|--------|----------|----------|
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
| 安装包 | 适用场景 | 包含组件 |
| --------------- | ---------------------------- | --------------------------------------------- |
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
**关键步骤:**
@@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
```
**选择建议:**
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
@@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
#### 2.1 注册实验室账号
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
2. 注册账号并登录
3. 创建新实验室
@@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
#### 5.2 访问 Web 界面
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
#### 5.3 添加设备和物料
@@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**示例场景:** 创建一个简单的液体转移实验
1. **添加工作站(必需):**
- 在"仪器设备"中找到 `work_station`
- 添加 `workstation` x1
2. **添加虚拟转移泵:**
- 在"仪器设备"中找到 `virtual_device`
- 添加 `virtual_transfer_pump` x1
@@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt
```
**为什么使用这种方式?**
- `unilabos-env` 提供 ROS2 核心组件和 uv通过 conda 安装,避免编译)
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
@@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \
**详细步骤:**
1. **需求分析**
- 明确实验流程
- 列出所需设备和物料
- 设计工作流程图
2. **环境搭建**
- 安装 Uni-Lab-OS
- 创建实验室账号
- 准备开发工具IDE、Git
3. **原型验证**
- 使用虚拟设备测试流程
- 验证工作流逻辑
- 调整参数
4. **迭代开发**
- 实现自定义设备驱动(同时撰写单点函数测试)
- 编写注册表
- 单元测试
- 集成测试
5. **测试部署**
- 连接真实硬件
- 空跑测试
- 小规模试验
@@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
#### 14.5 社区支持
- **GitHub Issues**[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
- **官方网站**[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
- **官方网站**[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
---

View File

@@ -626,7 +626,7 @@ unilab
**云端图文件管理**:
1. 登录 https://uni-lab.bohrium.com
1. 登录 https://leap-lab.bohrium.com
2. 进入"设备配置"
3. 创建或编辑配置
4. 保存到云端

View File

@@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
您可以直接跟随 unilabos 的提示进行,无需查阅本节
- **工作目录设置**
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
- 否则使用 `当前目录/unilabos_data` 作为工作目录
- 可通过 `--working_dir` 指定自定义工作目录
@@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
支持多种后端环境:
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
- 自定义地址:直接指定完整 URL
@@ -176,7 +175,7 @@ unilab --config path/to/your/config.py
如果是首次使用,系统会:
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
2. 引导创建配置文件
3. 设置工作目录
@@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
如果提示 "后续运行必须拥有一个实验室",请确保:
- 已在 https://uni-lab.bohrium.com 注册实验室
- 已在 https://leap-lab.bohrium.com 注册实验室
- 正确设置了 `--ak``--sk` 参数
- 配置文件中包含正确的认证信息

View File

@@ -233,7 +233,7 @@ def parse_args():
parser.add_argument(
"--addr",
type=str,
default="https://uni-lab.bohrium.com/api/v1",
default="https://leap-lab.bohrium.com/api/v1",
help="Laboratory backend address",
)
parser.add_argument(
@@ -438,10 +438,10 @@ def main():
if args.addr != parser.get_default("addr"):
if args.addr == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
elif args.addr == "uat":
print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1"
elif args.addr == "local":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
@@ -553,7 +553,7 @@ def main():
os._exit(0)
if not BasicConfig.ak or not BasicConfig.sk:
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
os._exit(1)
graph: nx.Graph
resource_tree_set: ResourceTreeSet

View File

@@ -36,6 +36,9 @@ class HTTPClient:
auth_secret = BasicConfig.auth_secret()
self.auth = auth_secret
info(f"正在使用ak sk作为授权信息[{auth_secret}]")
# 复用 TCP/TLS 连接,避免每次请求重新握手
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
@@ -48,7 +51,7 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material/edge",
json={
"edges": resources,
@@ -75,26 +78,28 @@ class HTTPClient:
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
# dump() 只调用一次,复用给文件保存和 HTTP 请求
nodes_info = [x for xs in resources.dump() for x in xs]
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
body_bytes = _fast_dumps(payload)
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
f.write(_fast_dumps_pretty(payload))
http_headers = {"Content-Type": "application/json"}
if not self.initialized or first_add:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material",
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
data=body_bytes,
headers=http_headers,
timeout=60,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/edge/material",
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
data=body_bytes,
headers=http_headers,
timeout=10,
)
@@ -133,7 +138,7 @@ class HTTPClient:
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
@@ -147,6 +152,7 @@ class HTTPClient:
logger.error(f"查询物料失败: {response.text}")
else:
data = res["data"]["nodes"]
logger.trace(f"resource_tree_get查询到物料: {data}")
return data
else:
logger.error(f"查询物料失败: {response.text}")
@@ -164,14 +170,14 @@ class HTTPClient:
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
@@ -198,7 +204,7 @@ class HTTPClient:
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
response = requests.get(
response = self._session.get(
f"{self.remote_addr}/lab/material",
params={"id": id, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
@@ -239,14 +245,14 @@ class HTTPClient:
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
@@ -276,7 +282,7 @@ class HTTPClient:
with open(file_path, "rb") as file:
files = {"files": file}
logger.info(f"上传文件: {file_path}{scene}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/api/account/file_upload/{scene}",
files=files,
headers={"Authorization": f"Lab {self.auth}"},
@@ -316,7 +322,7 @@ class HTTPClient:
"Content-Type": "application/json",
"Content-Encoding": "gzip",
}
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/resource",
data=compressed_body,
headers=headers,
@@ -350,7 +356,7 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = requests.get(
response = self._session.get(
f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30),
@@ -411,7 +417,7 @@ class HTTPClient:
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/workflow/owner/import",
json=payload,
headers={"Authorization": f"Lab {self.auth}"},

View File

@@ -1269,7 +1269,13 @@ class QueueProcessor:
if not queued_jobs:
return
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
queue_summary = {}
for j in queued_jobs:
key = f"{j.device_id}/{j.action_name}"
queue_summary[key] = queue_summary.get(key, 0) + 1
logger.debug(
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
)
for job_info in queued_jobs:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY

View File

@@ -46,7 +46,7 @@ class WSConfig:
# HTTP配置
class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1"
remote_addr = "https://leap-lab.bohrium.com/api/v1"
# ROS配置

View File

@@ -219,10 +219,10 @@ device = NewareBatteryTestSystem(
#### 步骤 2提交测试任务
使用 `submit_from_csv` 提交测试任务:
使用 `submit_from_csv_export_ndax` 提交测试任务:
```python
result = device.submit_from_csv(
result = device.submit_from_csv_export_ndax(
csv_path="test_data.csv",
output_dir="D:/neware_output"
)
@@ -489,7 +489,7 @@ A: 重新获取新的 Token 并更新环境变量 `UNI_LAB_AUTH_TOKEN`。
**Q: 可以自定义上传路径吗?**
A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。
**Q: 为什么不在 `submit_from_csv` 中自动上传?**
**Q: 为什么不在 `submit_from_csv_export_ndax` 中自动上传?**
A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。
**Q: 上传后如何访问文件?**

View File

@@ -230,10 +230,10 @@ device = NewareBatteryTestSystem(
#### Step 2: Submit Test Tasks
Use `submit_from_csv` to submit test tasks:
Use `submit_from_csv_export_ndax` to submit test tasks:
```python
result = device.submit_from_csv(
result = device.submit_from_csv_export_ndax(
csv_path="test_data.csv",
output_dir="D:/neware_output"
)
@@ -500,7 +500,7 @@ A: Obtain a new API Key and update the `UNI_LAB_AUTH_TOKEN` environment variable
**Q: Can I customize upload paths?**
A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility).
**Q: Why not auto-upload in `submit_from_csv`?**
**Q: Why not auto-upload in `submit_from_csv_export_ndax`?**
A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility.
**Q: How to access files after upload?**

View File

@@ -26,7 +26,7 @@
"data": {
"功能说明": "新威电池测试系统提供720通道监控和CSV批量提交功能",
"监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等",
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务NDA备份或通过submit_from_csv_export_excel action提交并备份为Excel格式。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
},
"children": []
}

View File

@@ -1358,4 +1358,287 @@ def xml_ZQXNLRMO(act_mass, Cap_mAh):
</config>
</root>
"""
return xml_data
return xml_data
def xml_811_Li_JY(act_mass=None, Cap_mAh=None):
"""
生成XML内容
参数:
act_mass: 可选,未使用
Cap_mAh: 可选,未使用
"""
xml_data = f"""<?xml version="1.0" encoding="utf-8"?>
<root>
<config type="Step File" version="18" client_version="BTS Client 8.0.1.492(2025.01.23)(R3)" date="20251210133911" Guid="8a47521b-79f9-40e7-baaa-3e462f26979a">
<Head_Info>
<Operate Value="66" />
<Scale Value="1" />
<Start_Step Value="1" Hide_Ctrl_Step="0" />
<PN Value="2025-08-05 19-42-25" />
<RateType Value="103" />
</Head_Info>
<Whole_Prt>
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
</Whole_Prt>
<Step_Info Num="13">
<Step1 Step_ID="1" Step_Type="4">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Time Value="43200000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step1>
<Step2 Step_ID="2" Step_Type="1">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Stop_Volt Value="43000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step2>
<Step3 Step_ID="3" Step_Type="3">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Volt Value="43000" />
<Stop_Curr Value="0.05" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step3>
<Step4 Step_ID="4" Step_Type="2">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Stop_Volt Value="27500" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step4>
<Step5 Step_ID="5" Step_Type="1">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Stop_Volt Value="43000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step5>
<Step6 Step_ID="6" Step_Type="2">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Stop_Volt Value="27500" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step6>
<Step7 Step_ID="7" Step_Type="1">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="1.03" />
<Stop_Volt Value="43000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step7>
<Step8 Step_ID="8" Step_Type="2">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="1.03" />
<Stop_Volt Value="27500" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step8>
<Step9 Step_ID="9" Step_Type="5">
<Limit>
<Other>
<Start_Step Value="7" />
<Cycle_Count Value="5" />
</Other>
</Limit>
</Step9>
<Step10 Step_ID="10" Step_Type="1">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="2.06" />
<Stop_Volt Value="43000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step10>
<Step11 Step_ID="11" Step_Type="2">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="2.06" />
<Stop_Volt Value="27500" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step11>
<Step12 Step_ID="12" Step_Type="5">
<Limit>
<Other>
<Start_Step Value="10" />
<Cycle_Count Value="500" />
</Other>
</Limit>
</Step12>
<Step13 Step_ID="13" Step_Type="6">
</Step13>
</Step_Info>
<SMBUS>
<SMBUS_Info Num="0" AdjacentInterval="0" />
</SMBUS>
</config>
</root>
"""
return xml_data

View File

@@ -16,13 +16,18 @@
import os
import sys
import socket
import csv
import xml.etree.ElementTree as ET
import json
import time
import inspect
from datetime import datetime
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, TypedDict
from pylabrobot.resources import ResourceHolder, Coordinate, create_ordered_items_2d, Deck, Plate
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
@@ -256,12 +261,27 @@ class BatteryTestPosition(ResourceHolder):
super().load_state(state)
self._unilabos_state = state
def serialize(self) -> dict:
d = super().serialize()
channel_name = self._unilabos_state.get("Channel_Name")
if channel_name:
d["name"] = channel_name
return d
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state)
return data
def serialize_all_state(self) -> Dict[str, Dict[str, Any]]:
states = {}
channel_name = self._unilabos_state.get("Channel_Name", self.name)
states[channel_name] = self.serialize_state()
for child in self.children:
states.update(child.serialize_all_state())
return states
class NewareBatteryTestSystem:
"""
@@ -292,13 +312,13 @@ class NewareBatteryTestSystem:
# ========================
STATUS_SET = {"working", "stop", "finish", "protect", "pause", "false"}
STATUS_COLOR = {
"working": "#22c55e", # 绿
"stop": "#6b7280", # 灰
"finish": "#3b82f6", # 蓝
"protect": "#ef4444", # 红
"pause": "#f59e0b", # 橙
"false": "#9ca3af", # 不存在/无效
"unknown": "#a855f7", # 未知
"working": "#15803d", # 绿
"stop": "#4b5563", #
"finish": "#1d4ed8", #
"protect": "#b91c1c", #
"pause": "#b45309", #
"false": "#6b7280", #
"unknown": "#7c3aed", # 深紫
}
# 字母常量
@@ -409,10 +429,10 @@ class NewareBatteryTestSystem:
"""设置物料管理系统"""
deck_main = Deck(
name="ADeckName",
size_x=2200,
size_x=1200,
size_y=2800,
size_z=100,
origin=Coordinate(2000, 2000, 0)
origin=Coordinate(-5500, 0, 0)
)
self.station_resources = {}
self.station_resources_by_plate = {}
@@ -432,19 +452,34 @@ class NewareBatteryTestSystem:
plate_name = self._plate_name(devid, plate_num)
plate = Plate(
name=plate_name,
size_x=400,
size_y=300,
size_x=540,
size_y=350,
size_z=50,
ordered_items=plate_resources
)
location_x = 0 if plate_num == 1 else 450
location_y = row_idx * 350
location_x = 0 if plate_num == 1 else 590
location_y = row_idx * 400
deck_main.assign_child_resource(plate, location=Coordinate(location_x, location_y, 0))
plate_key = (devid, plate_num)
subdev_start = 1 if plate_num == 1 else 6
self.station_resources_by_plate[plate_key] = {}
for name, resource in plate_resources.items():
new_name = f"{plate_name}_{name}"
# 从名称解析 col/row 索引,设置初始 Channel_Name
parts = name.rsplit("_", 2)
if len(parts) >= 3:
col_idx, row_idx = int(parts[-2]), int(parts[-1])
chl_id = col_idx + 1
subdev_id = subdev_start + row_idx
resource.load_state({
"status": "unknown",
"color": self.STATUS_COLOR["unknown"],
"voltage": 0.0,
"current": 0.0,
"time": 0.0,
"Channel_Name": f"{devid}-{subdev_id}-{chl_id}",
})
self.station_resources_by_plate[plate_key][new_name] = resource
self.station_resources[new_name] = resource
@@ -873,6 +908,28 @@ class NewareBatteryTestSystem:
def _canon(self, bs: str) -> str:
"""规范化电池体系名称"""
return str(bs).strip().replace('-', '_').upper()
def _get_builder_required_positional_count(self, builder) -> int:
"""返回XML生成函数必填位置参数个数仅统计无默认值的positional参数"""
sig = inspect.signature(builder)
required = 0
for p in sig.parameters.values():
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
if p.default is inspect.Parameter.empty:
required += 1
return required
def _is_csv_value_empty(self, value) -> bool:
"""判断CSV单元格是否为空兼容NaN/None/空串/null"""
if value is None:
return True
if isinstance(value, str):
return value.strip().lower() in ("", "nan", "none", "null")
try:
# NaN 与自身不相等
return value != value
except Exception:
return False
def _compute_values(self, row):
"""
@@ -884,7 +941,7 @@ class NewareBatteryTestSystem:
Returns:
tuple: (活性物质质量mg, 容量mAh)
"""
pw = float(row['Pole_Weight'])
pw = float(row['pole_weight'])
cm = float(row['集流体质量'])
am = row['活性物质含量']
if isinstance(am, str) and am.endswith('%'):
@@ -918,6 +975,7 @@ class NewareBatteryTestSystem:
'SIGR_LI': gen_mod.xml_SiGr_Li_Step,
'811_SIGR': gen_mod.xml_811_SiGr,
'811_CU_AGING': gen_mod.xml_811_Cu_aging,
'811_LI_JY': gen_mod.xml_811_Li_JY,
'ZQXNLRMO':gen_mod.xml_ZQXNLRMO,
}
if key not in fmap:
@@ -935,7 +993,7 @@ class NewareBatteryTestSystem:
with open(path, 'w', encoding='utf-8') as f:
f.write(xml)
def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict:
def submit_from_csv_export_ndax(self, csv_path: str, output_dir: str = ".") -> dict:
"""
从CSV文件批量提交Neware测试任务设备动作
@@ -967,8 +1025,7 @@ class NewareBatteryTestSystem:
# 验证必需列
required = [
'Battery_Code', 'Electrolyte_Code', 'Pole_Weight', '集流体质量', '活性物质含量',
'克容量mah/g', '电池体系', '设备号', '排号', '通道号'
'coin_cell_code', 'electrolyte_code', '电池体系', '设备号', '排号', '通道号'
]
missing = [c for c in required if c not in df.columns]
if missing:
@@ -997,27 +1054,47 @@ class NewareBatteryTestSystem:
for idx, row in df.iterrows():
try:
coin_id = f"{row['Battery_Code']}-{row['Electrolyte_Code']}"
# 计算活性物质质量和容量
act_mass, cap_mAh = self._compute_values(row)
if cap_mAh < 0:
error_msg = (
f"容量为负数: Battery_Code={coin_id}, "
f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}"
)
if self._ros_node:
self._ros_node.lab_logger().warning(error_msg)
results.append(f"{idx+1} 失败: {error_msg}")
continue
coin_id = f"{row['coin_cell_code']}-{row['electrolyte_code']}"
# 获取电池体系对应的XML生成函数
key = self._canon(row['电池体系'])
builder = self._get_xml_builder(gen_mod, key)
# 生成XML内容
xml_content = builder(act_mass, cap_mAh)
builder_required_args = self._get_builder_required_positional_count(builder)
# 生成XML内容仅当工步模板需要时才校验并计算 act_mass/cap_mAh
if builder_required_args == 0:
xml_content = builder()
elif builder_required_args == 2:
calc_cols = ['pole_weight', '集流体质量', '活性物质含量', '克容量mah/g']
missing_calc = [
c for c in calc_cols
if c not in df.columns or self._is_csv_value_empty(row[c])
]
if missing_calc:
error_msg = (
f"电池体系 {key} 需要 act_mass/Cap_mAh以下列缺失或为空: {missing_calc}, "
f"CoinID={coin_id}"
)
if self._ros_node:
self._ros_node.lab_logger().warning(error_msg)
results.append(f"{idx+1} 失败: {error_msg}")
continue
act_mass, cap_mAh = self._compute_values(row)
if cap_mAh < 0:
error_msg = (
f"容量为负数: Battery_Code={coin_id}, "
f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}"
)
if self._ros_node:
self._ros_node.lab_logger().warning(error_msg)
results.append(f"{idx+1} 失败: {error_msg}")
continue
xml_content = builder(act_mass, cap_mAh)
else:
raise ValueError(
f"XML生成函数参数不支持: {builder.__name__} 需要 {builder_required_args} 个必填位置参数"
)
# 获取设备信息
devid = int(row['设备号'])
@@ -1040,7 +1117,8 @@ class NewareBatteryTestSystem:
chlid=chlid,
CoinID=coin_id,
recipe_path=recipe_path,
backup_dir=backup_dir
backup_dir=backup_dir,
filetype=0
)
submitted_count += 1
@@ -1048,7 +1126,7 @@ class NewareBatteryTestSystem:
if self._ros_node:
self._ros_node.lab_logger().info(
f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}): {resp}"
f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}, NDAX备份): {resp}"
)
except Exception as e:
@@ -1088,6 +1166,168 @@ class NewareBatteryTestSystem:
}
def submit_from_csv_export_excel(self, csv_path: str, output_dir: str = ".") -> dict:
"""
从CSV文件批量提交Neware测试任务备份格式为Excel设备动作
与 submit_from_csv_export_ndax 逻辑一致,唯一区别是 BTS 备份文件格式为 Excel 而非 NDA。
Args:
csv_path (str): 输入CSV文件路径
output_dir (str): 输出目录用于存储XML文件和备份默认当前目录
Returns:
dict: 执行结果 {"return_info": str, "success": bool, "submitted_count": int}
"""
try:
self._ensure_local_import_path()
import pandas as pd
import generate_xml_content as gen_mod
from neware_driver import start_test
if self._ros_node:
self._ros_node.lab_logger().info(f"开始从CSV文件提交任务(Excel备份): {csv_path}")
if not os.path.exists(csv_path):
error_msg = f"CSV文件不存在: {csv_path}"
if self._ros_node:
self._ros_node.lab_logger().error(error_msg)
return {"return_info": error_msg, "success": False, "submitted_count": 0, "total_count": 0}
df = pd.read_csv(csv_path, encoding='gbk')
required = [
'coin_cell_code', 'electrolyte_code', '电池体系', '设备号', '排号', '通道号'
]
missing = [c for c in required if c not in df.columns]
if missing:
error_msg = f"CSV缺少必需列: {missing}"
if self._ros_node:
self._ros_node.lab_logger().error(error_msg)
return {"return_info": error_msg, "success": False, "submitted_count": 0, "total_count": 0}
xml_dir = os.path.join(output_dir, 'xml_dir')
backup_dir = os.path.join(output_dir, 'backup_dir')
os.makedirs(xml_dir, exist_ok=True)
os.makedirs(backup_dir, exist_ok=True)
self._last_backup_dir = backup_dir
if self._ros_node:
self._ros_node.lab_logger().info(
f"输出目录: XML={xml_dir}, 备份(Excel)={backup_dir}"
)
submitted_count = 0
results = []
for idx, row in df.iterrows():
try:
coin_id = f"{row['coin_cell_code']}-{row['electrolyte_code']}"
key = self._canon(row['电池体系'])
builder = self._get_xml_builder(gen_mod, key)
builder_required_args = self._get_builder_required_positional_count(builder)
if builder_required_args == 0:
xml_content = builder()
elif builder_required_args == 2:
calc_cols = ['pole_weight', '集流体质量', '活性物质含量', '克容量mah/g']
missing_calc = [
c for c in calc_cols
if c not in df.columns or self._is_csv_value_empty(row[c])
]
if missing_calc:
error_msg = (
f"电池体系 {key} 需要 act_mass/Cap_mAh以下列缺失或为空: {missing_calc}, "
f"CoinID={coin_id}"
)
if self._ros_node:
self._ros_node.lab_logger().warning(error_msg)
results.append(f"{idx+1} 失败: {error_msg}")
continue
act_mass, cap_mAh = self._compute_values(row)
if cap_mAh < 0:
error_msg = (
f"容量为负数: Battery_Code={coin_id}, "
f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}"
)
if self._ros_node:
self._ros_node.lab_logger().warning(error_msg)
results.append(f"{idx+1} 失败: {error_msg}")
continue
xml_content = builder(act_mass, cap_mAh)
else:
raise ValueError(
f"XML生成函数参数不支持: {builder.__name__} 需要 {builder_required_args} 个必填位置参数"
)
devid = int(row['设备号'])
subdevid = int(row['排号'])
chlid = int(row['通道号'])
recipe_path = os.path.join(
xml_dir,
f"{coin_id}_{devid}_{subdevid}_{chlid}.xml"
)
self._save_xml(xml_content, recipe_path)
resp = start_test(
ip=self.ip,
port=self.port,
devid=devid,
subdevid=subdevid,
chlid=chlid,
CoinID=coin_id,
recipe_path=recipe_path,
backup_dir=backup_dir,
filetype=1
)
submitted_count += 1
results.append(f"{idx+1} {coin_id}: {resp}")
if self._ros_node:
self._ros_node.lab_logger().info(
f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}, Excel备份): {resp}"
)
except Exception as e:
error_msg = f"{idx+1} 处理失败: {str(e)}"
results.append(error_msg)
if self._ros_node:
self._ros_node.lab_logger().error(error_msg)
success_msg = (
f"批量提交完成(Excel备份): 成功{submitted_count}个,共{len(df)}行。"
f"\n详细结果:\n" + "\n".join(results)
)
if self._ros_node:
self._ros_node.lab_logger().info(
f"批量提交完成(Excel备份): 成功{submitted_count}/{len(df)}"
)
return {
"return_info": success_msg,
"success": True,
"submitted_count": submitted_count,
"total_count": len(df),
"results": results
}
except Exception as e:
error_msg = f"批量提交失败(Excel备份): {str(e)}"
if self._ros_node:
self._ros_node.lab_logger().error(error_msg)
return {
"return_info": error_msg,
"success": False,
"submitted_count": 0,
"total_count": 0
}
def get_device_summary(self) -> dict:
"""
获取设备级别的摘要统计(设备动作)
@@ -1164,7 +1404,7 @@ class NewareBatteryTestSystem:
上传备份目录中的文件到 OSSROS2 动作)
Args:
backup_dir: 备份目录路径,默认使用最近一次 submit_from_csv 的 backup_dir
backup_dir: 备份目录路径,默认使用最近一次提交任务的 backup_dir
file_pattern: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件)
oss_prefix: OSS 对象前缀,默认使用类初始化时的配置
@@ -1694,6 +1934,474 @@ class NewareBatteryTestSystem:
return result
def mock_assembly_data(self) -> dict:
"""
模拟扣电组装站 auto-func_sendbottle_allpack_multi 的输出,返回固定的 2 颗电池 assembly_data。
用于在没有真实扣电组装站的情况下,测试
mock_assembly_data → manual_confirm → battery_transfer_confirm → submit_auto_export_excel
的完整参数传递与 TCP 下发链路。
Returns:
dict: {
"assembly_data": list[dict], # 9 字段 × 2 颗电池
"success": bool,
"return_info": str,
}
"""
assembly_data = [
{
"Time": "20260421_143022",
"open_circuit_voltage": 3.721,
"pole_weight": 98.43,
"assembly_time": 120,
"assembly_pressure": 5.2,
"electrolyte_volume": 80.0,
"data_coin_type": 2,
"electrolyte_code": "EL-2026042101",
"coin_cell_code": "CC-2026042101",
},
{
"Time": "20260421_143255",
"open_circuit_voltage": 3.698,
"pole_weight": 97.85,
"assembly_time": 118,
"assembly_pressure": 5.1,
"electrolyte_volume": 79.5,
"data_coin_type": 2,
"electrolyte_code": "EL-2026042102",
"coin_cell_code": "CC-2026042102",
},
]
info = f"mock_assembly_data 返回 {len(assembly_data)} 颗电池的模拟组装数据"
if self._ros_node:
self._ros_node.lab_logger().info(f"[mock_assembly_data] {info}")
else:
print(f"[mock_assembly_data] {info}")
return {
"assembly_data": assembly_data,
"success": True,
"return_info": info,
}
def manual_confirm(
self,
resource: List[ResourceSlot],
target_device: DeviceSlot,
mount_resource: List[ResourceSlot],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: List[str],
formulations: List[Dict] = None,
assembly_data: List[Dict] = None,
csv_export_dir: str = "D:\\2604Agentic_test",
timeout_seconds: int = 3600,
assignee_user_ids: list[str] = None,
**kwargs,
) -> dict:
"""
人工确认节点:
- 上游接收 bioyond 配方formulations+ 扣电组装数据assembly_data 单数组)
- 人工在前端填入 collector_mass / active_material / capacity / battery_system(xml工步)
并选择 target_device 与 mount_resource通道
- 内部把 assembly_data 解包为 9 个并行数组,把 pole_weight 透传给下游 submit_auto_export_excel
- 把所有数据整合后写入 {csv_export_dir}/{YYYYMMDD}/date_{YYYYMMDD}.csv
Args:
timeout_seconds: 超时时间(秒),默认 3600
collector_mass: 极流体质量 (mg)
active_material: 活性物质含量 (0.97 或 "97%")
capacity: 克容量 (mAh/g)
battery_system: xml 工步标识(如 "811_LI_002"
formulations: 配方信息列表(来自 bioyond mass_ratios
assembly_data: 扣电组装数据列表(每颗电池一个 dict
csv_export_dir: 整合 CSV 导出根目录
"""
resource_dump = ResourceTreeSet.from_plr_resources(resource).dump()
mount_resource_dump = ResourceTreeSet.from_plr_resources(mount_resource).dump()
assembly_data = assembly_data or []
formulations = formulations or []
Time = [b.get("Time", "") for b in assembly_data]
open_circuit_voltage = [b.get("open_circuit_voltage", 0.0) for b in assembly_data]
pole_weight = [b.get("pole_weight", 0.0) for b in assembly_data]
assembly_time = [b.get("assembly_time", 0) for b in assembly_data]
assembly_pressure = [b.get("assembly_pressure", 0) for b in assembly_data]
electrolyte_volume = [b.get("electrolyte_volume", 0) for b in assembly_data]
data_coin_type = [b.get("data_coin_type", 0) for b in assembly_data]
electrolyte_code = [b.get("electrolyte_code", "") for b in assembly_data]
coin_cell_code = [b.get("coin_cell_code", "") for b in assembly_data]
try:
self._export_manual_confirm_csv(
csv_export_dir=csv_export_dir,
mount_resource=mount_resource,
formulations=formulations,
assembly_rows={
"Time": Time,
"open_circuit_voltage": open_circuit_voltage,
"pole_weight": pole_weight,
"assembly_time": assembly_time,
"assembly_pressure": assembly_pressure,
"electrolyte_volume": electrolyte_volume,
"data_coin_type": data_coin_type,
"electrolyte_code": electrolyte_code,
"coin_cell_code": coin_cell_code,
},
collector_mass=collector_mass,
active_material=active_material,
capacity=capacity,
battery_system=battery_system,
)
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"[manual_confirm] 整合 CSV 导出失败: {e}")
else:
print(f"[manual_confirm] 整合 CSV 导出失败: {e}")
return {
"resource": resource_dump,
"coin_cell_code": coin_cell_code,
"electrolyte_code": electrolyte_code,
"target_device": target_device,
"mount_resource": mount_resource_dump,
"collector_mass": collector_mass,
"active_material": active_material,
"capacity": capacity,
"battery_system": battery_system,
"pole_weight": pole_weight,
}
def _export_manual_confirm_csv(
self,
csv_export_dir: str,
mount_resource: List[ResourceSlot],
formulations: List[Dict],
assembly_rows: Dict[str, List[Any]],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: List[str],
) -> Optional[str]:
"""把 manual_confirm 收集到的全部参数整合写入 CSV。路径{csv_export_dir}/{YYYYMMDD}/date_{YYYYMMDD}.csv"""
n_assembly = len(assembly_rows.get("Time", []))
n_channel = len(mount_resource) if mount_resource else 0
n = max(n_assembly, n_channel, len(collector_mass or []), len(active_material or []),
len(capacity or []), len(battery_system or []))
if n == 0:
return None
date_str = datetime.now().strftime("%Y%m%d")
out_dir = os.path.join(csv_export_dir, date_str)
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, f"date_{date_str}.csv")
header = [
"Time", "open_circuit_voltage", "pole_weight",
"assembly_time", "assembly_pressure", "electrolyte_volume",
"data_coin_type", "electrolyte_code", "coin_cell_code",
"orderName", "prep_bottle_barcode", "vial_bottle_barcodes",
"target_mass_ratio", "real_mass_ratio",
"collector_mass", "active_material", "capacity", "battery_system",
"channel_name",
]
file_exists = os.path.exists(out_path)
with open(out_path, "a", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
if not file_exists:
writer.writerow(header)
def safe_get(lst, i, default=""):
try:
return lst[i] if lst and i < len(lst) else default
except Exception:
return default
for i in range(n):
form = formulations[i] if formulations and i < len(formulations) else {}
target_ratio = form.get("target_mass_ratio", {}) if isinstance(form, dict) else {}
real_ratio = form.get("real_mass_ratio", {}) if isinstance(form, dict) else {}
ch_name = self._extract_channel_name(mount_resource[i]) if mount_resource and i < len(mount_resource) else ""
writer.writerow([
safe_get(assembly_rows["Time"], i),
safe_get(assembly_rows["open_circuit_voltage"], i, 0.0),
safe_get(assembly_rows["pole_weight"], i, 0.0),
safe_get(assembly_rows["assembly_time"], i, 0),
safe_get(assembly_rows["assembly_pressure"], i, 0),
safe_get(assembly_rows["electrolyte_volume"], i, 0),
safe_get(assembly_rows["data_coin_type"], i, 0),
safe_get(assembly_rows["electrolyte_code"], i),
safe_get(assembly_rows["coin_cell_code"], i),
form.get("orderName", "") if isinstance(form, dict) else "",
form.get("prep_bottle_barcode", "") if isinstance(form, dict) else "",
form.get("vial_bottle_barcodes", "") if isinstance(form, dict) else "",
json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "",
json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "",
safe_get(collector_mass, i, ""),
safe_get(active_material, i, ""),
safe_get(capacity, i, ""),
safe_get(battery_system, i, ""),
(f"'{ch_name}" if ch_name else ""),
])
f.flush()
if self._ros_node:
self._ros_node.lab_logger().info(f"[manual_confirm] 整合 CSV 已写入 {out_path}{n} 行)")
return out_path
async def battery_transfer_confirm(
self,
resource: List[ResourceSlot],
target_device: DeviceSlot,
mount_resource: List[ResourceSlot],
timeout_seconds: int = 3600,
assignee_user_ids: list[str] = None,
**kwargs,
):
"""
电池装夹人工确认 + TCP 转运。
- 该节点通过 yaml 的 node_type: manual_confirm 机制阻塞等待人工确认。
- 人工在前端确认通道与电池对应关系(装夹就位)后,方法体才会被框架调用。
- 方法体执行真正的 TCP 资源转运。
"""
future = ROS2DeviceNode.run_async_func(
self._ros_node.transfer_resource_to_another, True,
**{
"plr_resources": resource,
"target_device_id": target_device,
"target_resources": mount_resource,
"sites": [None] * len(mount_resource),
},
)
result = await future
return result
# ──────────────────────────────────────────────
# test() 辅助方法
# ──────────────────────────────────────────────
@staticmethod
def _extract_channel_name(res) -> Optional[str]:
"""从 BatteryTestPosition 或通用 Resource 中提取 Channel_Name (devid-subdevid-chlid)"""
# 情况1: ResourceSlot 对象 —— 直接读 _unilabos_state
state = getattr(res, "_unilabos_state", None)
if isinstance(state, dict):
ch = state.get("Channel_Name")
if ch:
return str(ch)
# 情况2: serialize_state()
if hasattr(res, "serialize_state"):
try:
ss = res.serialize_state()
if isinstance(ss, dict):
ch = ss.get("Channel_Name")
if ch:
return str(ch)
except Exception:
pass
# 情况3: 来自 ResourceTreeSet.dump() 的 dict
if isinstance(res, dict):
data = res.get("data", {})
if isinstance(data, dict):
ch = data.get("Channel_Name")
if ch:
return str(ch)
ch = res.get("name") or res.get("id")
if ch and len(str(ch).split("-")) == 3:
return str(ch)
# 情况4: name 本身就是 "devid-subdevid-chlid"
name = getattr(res, "name", "")
if name and len(name.split("-")) == 3:
return name
return None
@staticmethod
def _extract_pole_weight(res) -> float:
"""从电池资源 state 中提取极片称重 (mg)"""
state = getattr(res, "_unilabos_state", None)
if isinstance(state, dict) and "pole_weight" in state:
return float(state["pole_weight"])
if hasattr(res, "serialize_state"):
try:
ss = res.serialize_state()
if isinstance(ss, dict) and "pole_weight" in ss:
return float(ss["pole_weight"])
except Exception:
pass
if isinstance(res, dict):
data = res.get("data", {})
if isinstance(data, dict) and "pole_weight" in data:
return float(data["pole_weight"])
return 0.0
@staticmethod
def _parse_active_material(val) -> float:
"""解析活性物质含量,支持 0.97 或 '97%' 两种格式"""
if isinstance(val, str):
val = val.strip()
if val.endswith("%"):
return float(val[:-1]) / 100.0
return float(val)
return float(val)
# ──────────────────────────────────────────────
# test 动作:下发测试
# ──────────────────────────────────────────────
async def submit_auto_export_excel(
self,
mount_resource: List[ResourceSlot],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: List[str],
pole_weight: List[float] = None,
coin_cell_code: List[str] = None,
electrolyte_code: List[str] = None,
resource: List[ResourceSlot] = None,
output_dir: str = "D:\\2604Agentic_test",
) -> dict:
"""
对每颗电池计算测试参数、生成 XML 工步文件并通过 TCP 下发给新威测试仪。
循环长度由 mount_resource 驱动(真正要下发的通道数量)。
Args:
mount_resource: 目标通道资源列表(含 Channel_Name = devid-subdevid-chlid循环长度来源
collector_mass: 各电池集流体质量 (mg)
active_material: 各电池活性物质比例0.97 或 "97%"
capacity: 各电池克容量 (mAh/g)
battery_system: xml 工步标识(如 "811_LI_002"
pole_weight: 各电池极片质量 (mg),来自上游 manual_confirm 的透传;为空时回退到从 resource 状态提取
coin_cell_code: 各电池条码(来自上游 manual_confirm 从 assembly_data 解包);作为 Neware 备份文件的 CoinID/barcode
resource: 成品电池资源列表(可选);仅在 coin_cell_code 与 pole_weight 均未提供时作为回退
"""
import importlib
gen_mod = importlib.import_module(
"unilabos.devices.neware_battery_test_system.generate_xml_content"
)
from .neware_driver import start_test as _start_test
resource = resource or []
pole_weight = pole_weight or []
coin_cell_code = coin_cell_code or []
electrolyte_code = electrolyte_code or []
n = len(mount_resource) if mount_resource else 0
results = []
submitted = 0
if n == 0:
msg = "mount_resource 为空,没有通道可下发"
if self._ros_node:
self._ros_node.lab_logger().warning(f"[test] {msg}")
return {
"return_info": f"共 0 颗电池,成功下发 0 颗({msg}",
"success": False,
"submitted_count": 0,
"total_count": 0,
"results": [],
}
xml_dir = os.path.join(output_dir, "xml_dir")
os.makedirs(xml_dir, exist_ok=True)
backup_dir = os.path.join(output_dir, "backup_dir")
os.makedirs(backup_dir, exist_ok=True)
for i in range(n):
try:
# 1. 解析通道地址
ch_name = self._extract_channel_name(mount_resource[i])
if not ch_name:
raise ValueError(f"无法从 mount_resource[{i}] 提取 Channel_Name")
parts = ch_name.split("-")
if len(parts) != 3:
raise ValueError(f"Channel_Name 格式错误,期望 devid-subdevid-chlid实际: {ch_name}")
devid, subdevid, chlid = int(parts[0]), int(parts[1]), int(parts[2])
# 2. 获取电池标识与极片重量(按优先级 coin_cell_code > resource > 兜底)
res = resource[i] if i < len(resource) else None
base_coin = (
(coin_cell_code[i] if i < len(coin_cell_code) and coin_cell_code[i] else None)
or (getattr(res, "name", None) if res is not None else None)
or (res.get("name") if isinstance(res, dict) else None)
or f"battery_{i}"
)
elec_code = electrolyte_code[i] if i < len(electrolyte_code) and electrolyte_code[i] else ""
coin_id = f"{base_coin}-{elec_code}-{devid}-{subdevid}-{chlid}"
if pole_weight and i < len(pole_weight):
pw = float(pole_weight[i])
elif res is not None:
pw = self._extract_pole_weight(res)
else:
raise ValueError(f"无法获取 pole_weightpole_weight 列表长度不足 且 resource 为空")
# 3. 计算活性物质质量与容量
cm = float(collector_mass[i])
amv = self._parse_active_material(active_material[i])
sc = float(capacity[i])
act_mass = round((pw - cm) * amv, 4)
if act_mass <= 0:
raise ValueError(
f"活性物质质量异常: pole_weight={pw}mg, collector_mass={cm}mg, "
f"active_material={amv}, act_mass={act_mass}"
)
cap_mAh = round(act_mass * sc / 1000.0, 4)
if cap_mAh <= 0:
raise ValueError(f"容量计算异常: act_mass={act_mass}mg, capacity={sc}mAh/g, cap_mAh={cap_mAh}")
# 4. 生成 XML 工步文件
key = self._canon(battery_system[i])
builder = self._get_xml_builder(gen_mod, key)
req_args = self._get_builder_required_positional_count(builder)
xml_content = builder(act_mass, cap_mAh) if req_args >= 2 else builder()
recipe_path = os.path.join(xml_dir, f"{coin_id}_{devid}_{subdevid}_{chlid}.xml")
self._save_xml(xml_content, recipe_path)
# 5. TCP 下发测试
resp = _start_test(
ip=self.ip,
port=int(self.port),
devid=devid,
subdevid=subdevid,
chlid=chlid,
CoinID=coin_id,
recipe_path=recipe_path,
backup_dir=backup_dir,
filetype=1,
)
submitted += 1
results.append({
"index": i,
"coin_id": coin_id,
"channel": ch_name,
"act_mass_mg": act_mass,
"cap_mAh": cap_mAh,
"success": True,
"response": str(resp)[:300],
})
if self._ros_node:
self._ros_node.lab_logger().info(
f"[test] 已下发 {coin_id}{ch_name} "
f"act_mass={act_mass}mg cap={cap_mAh}mAh"
)
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"[test] 电池[{i}] 下发失败: {e}")
results.append({"index": i, "success": False, "error": str(e)})
summary = f"{n} 颗电池,成功下发 {submitted}"
return {
"return_info": summary,
"success": submitted > 0,
"submitted_count": submitted,
"total_count": n,
"results": results,
}
# ========================
# 示例和测试代码

View File

@@ -5,14 +5,18 @@ def build_start_command(devid, subdevid, chlid, CoinID,
ip_in_xml="127.0.0.1",
devtype:int=27,
recipe_path:str=f"D:\\HHM_test\\A001.xml",
backup_dir:str=f"D:\\HHM_test\\backup") -> str:
backup_dir:str=f"D:\\HHM_test\\backup",
filetype:int=1) -> str:
"""
filetype: 备份文件类型。0=NDA新威原生1=Excel。默认 1。
"""
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<bts version="1.0">',
' <cmd>start</cmd>',
' <list count="1">',
f' <start ip="{ip_in_xml}" devtype="{devtype}" devid="{devid}" subdevid="{subdevid}" chlid="{chlid}" barcode="{CoinID}">{recipe_path}</start>',
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="0" backupontime="1" backupontimeinterval="1" backupfree="0" />',
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="{int(filetype)}" backupontime="1" backupontimeinterval="1" backupfree="0" />',
' </list>',
'</bts>',
]
@@ -36,8 +40,11 @@ def recv_until_marks(sock: socket.socket, timeout=60):
return bytes(buf)
return bytes(buf)
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup"):
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir)
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup", filetype:int=1):
"""
filetype: 备份文件类型0=NDA1=Excel。默认 1。
"""
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir, filetype=filetype)
#print(xml_cmd)
with socket.create_connection((ip, port), timeout=60) as s:
s.sendall(xml_cmd.encode("utf-8"))

View File

@@ -22,10 +22,11 @@ from threading import Lock, RLock
from typing_extensions import TypedDict
from unilabos.registry.decorators import (
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
)
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
# ============ TypedDict 返回类型定义 ============
@@ -290,6 +291,126 @@ class VirtualWorkbench:
self._update_data_status(f"机械臂已释放 (完成: {task})")
self.logger.info(f"机械臂已释放 (完成: {task})")
@action(
always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
"assignee_user_ids": "unilabos_manual_confirm"
}, goal_default={
"timeout_seconds": 3600,
"assignee_user_ids": []
}, feedback_interval=300,
handles=[
ActionInputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
ActionInputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
ActionInputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
ActionInputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
# transfer使用
ActionOutputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR),
# test使用
ActionOutputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR),
]
)
def manual_confirm(
self,
resource: List[ResourceSlot],
target_device: DeviceSlot,
mount_resource: List[ResourceSlot],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: List[str],
timeout_seconds: int,
assignee_user_ids: list[str],
**kwargs
) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
collector_mass: 极流体质量
active_material: 活性物质含量
capacity: 克容量mAh/g
battery_system: 电池体系
修改的结果无效,是只读的
"""
resource = ResourceTreeSet.from_plr_resources(resource).dump()
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
kwargs.update(locals())
kwargs.pop("kwargs")
kwargs.pop("self")
return kwargs
@action(
description="转移物料",
handles=[
ActionInputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
]
)
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
**{
"plr_resources": resource,
"target_device_id": target_device,
"target_resources": mount_resource,
"sites": [None] * len(mount_resource),
})
result = await future
return result
@action(
description="扣电测试启动",
handles=[
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
ActionInputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
ActionInputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
ActionInputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
]
)
async def test(
self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str]
):
print(resource)
print(mount_resource)
print(collector_mass)
print(active_material)
print(capacity)
print(battery_system)
@action(
auto_prefix=True,
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",

View File

@@ -1650,6 +1650,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
time_date = datetime.now().strftime("%Y%m%d")
#秒级时间戳用于标记每一行电池数据
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self._last_assembly_timestamp = timestamp
#生成输出文件的变量
self.csv_export_file = os.path.join(file_path, f"date_{time_date}.csv")
#将数据存入csv文件
@@ -1660,7 +1661,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
writer.writerow([
'Time', 'open_circuit_voltage', 'pole_weight',
'assembly_time', 'assembly_pressure', 'electrolyte_volume',
'coin_num', 'electrolyte_code', 'coin_cell_code',
'data_coin_type', 'electrolyte_code', 'coin_cell_code',
'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes',
'target_mass_ratio', 'real_mass_ratio'
])
@@ -1877,17 +1878,18 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
pole_weight = 0.0
battery_info = {
"battery_index": coin_num_N + 1,
"battery_barcode": battery_qr_code,
"electrolyte_barcode": electrolyte_qr_code,
"Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
"open_circuit_voltage": open_circuit_voltage,
"pole_weight": pole_weight,
"assembly_time": self.data_assembly_time,
"assembly_pressure": self.data_assembly_pressure,
"electrolyte_volume": self.data_electrolyte_volume
"electrolyte_volume": self.data_electrolyte_volume,
"data_coin_type": getattr(self, "data_coin_type", 0),
"electrolyte_code": electrolyte_qr_code,
"coin_cell_code": battery_qr_code,
}
battery_data_list.append(battery_info)
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}")
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}")
time.sleep(1)
# TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑
@@ -1916,6 +1918,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
"success": True,
"total_batteries": len(battery_data_list),
"batteries": battery_data_list,
"assembly_data": battery_data_list,
"summary": {
"electrolyte_bottles_used": elec_num,
"batteries_per_bottle": elec_use_num,
@@ -2130,17 +2133,18 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
pole_weight = 0.0
battery_info = {
"battery_index": coin_num_N + 1,
"battery_barcode": battery_qr_code,
"electrolyte_barcode": electrolyte_qr_code,
"Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
"open_circuit_voltage": open_circuit_voltage,
"pole_weight": pole_weight,
"assembly_time": self.data_assembly_time,
"assembly_pressure": self.data_assembly_pressure,
"electrolyte_volume": self.data_electrolyte_volume
"electrolyte_volume": self.data_electrolyte_volume,
"data_coin_type": getattr(self, "data_coin_type", 0),
"electrolyte_code": electrolyte_qr_code,
"coin_cell_code": battery_qr_code,
}
battery_data_list.append(battery_info)
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}")
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}")
time.sleep(1)
@@ -2167,6 +2171,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
"success": True,
"total_batteries": len(battery_data_list),
"batteries": battery_data_list,
"assembly_data": battery_data_list,
"summary": {
"electrolyte_bottles_used": elec_num,
"batteries_per_bottle": elec_use_num,

View File

@@ -825,6 +825,7 @@ def _extract_class_body(
action_args.setdefault("placeholder_keys", {})
action_args.setdefault("always_free", False)
action_args.setdefault("is_protocol", False)
action_args.setdefault("feedback_interval", 1.0)
action_args.setdefault("description", "")
action_args.setdefault("auto_prefix", False)
action_args.setdefault("parent", False)

View File

@@ -343,6 +343,7 @@ def action(
auto_prefix: bool = False,
parent: bool = False,
node_type: Optional["NodeType"] = None,
feedback_interval: Optional[float] = None,
):
"""
动作方法装饰器
@@ -378,9 +379,16 @@ def action(
"""
def decorator(func: F) -> F:
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
import asyncio as _asyncio
if _asyncio.iscoroutinefunction(func):
@wraps(func)
async def wrapper(*args, **kwargs):
return await func(*args, **kwargs)
else:
@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
@@ -399,6 +407,8 @@ def action(
"auto_prefix": auto_prefix,
"parent": parent,
}
if feedback_interval is not None:
meta["feedback_interval"] = feedback_interval
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]

View File

@@ -486,6 +486,12 @@ coincellassemblyworkstation_device:
data_type: array
handler_key: formulations_input
label: 配方信息列表
output:
- data_key: assembly_data
data_source: executor
data_type: array
handler_key: assembly_data_output
label: 扣电组装数据列表
placeholder_keys: {}
result: {}
schema:

View File

@@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel:
type: object
model:
mesh: thermo_orbitor_rs2_hotel
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
type: device
version: 1.0.0

File diff suppressed because it is too large Load Diff

View File

@@ -329,7 +329,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: object
model:
mesh: arm_slider
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
type: device
version: 1.0.0
robotic_arm.UR:

View File

@@ -238,6 +238,7 @@ class Registry:
"class_name": "unilabos_class",
},
"always_free": True,
"feedback_interval": 300.0,
},
"test_latency": test_latency_action,
"auto-test_resource": test_resource_action,
@@ -829,8 +830,9 @@ class Registry:
raw_handles = (action_args or {}).get("handles")
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
# placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测
pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params)
# placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充
pk = detect_placeholder_keys(params)
pk.update((action_args or {}).get("placeholder_keys") or {})
# 从方法返回值类型生成 result schema
result_schema = None
@@ -852,6 +854,8 @@ class Registry:
}
if (action_args or {}).get("always_free") or method_info.get("always_free"):
entry["always_free"] = True
_fb_iv = (action_args or {}).get("feedback_interval", method_info.get("feedback_interval", 1.0))
entry["feedback_interval"] = _fb_iv
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
if nt:
entry["node_type"] = nt
@@ -975,10 +979,12 @@ class Registry:
"schema": schema,
"goal_default": goal_default,
"handles": handles,
"placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params),
"placeholder_keys": {**detect_placeholder_keys(method_params), **(action_args.get("placeholder_keys") or {})},
}
if action_args.get("always_free") or method_info.get("always_free"):
action_entry["always_free"] = True
_fb_iv = action_args.get("feedback_interval", method_info.get("feedback_interval", 1.0))
action_entry["feedback_interval"] = _fb_iv
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
if nt:
action_entry["node_type"] = nt

View File

@@ -17,7 +17,7 @@ hplc_plate:
- 0
- 0
- 3.1416
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro
type: resource
version: 1.0.0
plate_96:
@@ -39,7 +39,7 @@ plate_96:
- 0
- 0
- 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro
type: resource
version: 1.0.0
plate_96_high:
@@ -61,7 +61,7 @@ plate_96_high:
- 1.5708
- 0
- 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro
type: resource
version: 1.0.0
tiprack_96_high:
@@ -76,7 +76,7 @@ tiprack_96_high:
init_param_schema: {}
model:
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_tf:
- 0.0018
- 0.0018
@@ -92,7 +92,7 @@ tiprack_96_high:
- 1.5708
- 0
- 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro
type: resource
version: 1.0.0
tiprack_box:
@@ -107,7 +107,7 @@ tiprack_box:
init_param_schema: {}
model:
children_mesh: tip/meshes/tip.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro
children_mesh_tf:
- 0.0045
- 0.0045
@@ -123,6 +123,6 @@ tiprack_box:
- 0
- 0
- 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro
type: resource
version: 1.0.0

View File

@@ -11,7 +11,7 @@ bottle_container:
init_param_schema: {}
model:
children_mesh: bottle/meshes/bottle.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro
children_mesh_tf:
- 0.04
- 0.04
@@ -27,7 +27,7 @@ bottle_container:
- 0
- 0
- 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro
type: resource
version: 1.0.0
tube_container:
@@ -43,7 +43,7 @@ tube_container:
init_param_schema: {}
model:
children_mesh: tube/meshes/tube.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro
children_mesh_tf:
- 0.017
- 0.017
@@ -59,6 +59,6 @@ tube_container:
- 0
- 0
- 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro
type: resource
version: 1.0.0

View File

@@ -10,6 +10,6 @@ TransformXYZDeck:
init_param_schema: {}
model:
mesh: liquid_transform_xyz
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro
type: device
version: 1.0.0

View File

@@ -10,7 +10,7 @@ OTDeck:
init_param_schema: {}
model:
mesh: opentrons_liquid_handler
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro
type: device
version: 1.0.0
hplc_station:
@@ -25,6 +25,6 @@ hplc_station:
init_param_schema: {}
model:
mesh: hplc_station
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro
type: device
version: 1.0.0

View File

@@ -109,7 +109,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
init_param_schema: {}
model:
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_tf:
- 0.0018
- 0.0018
@@ -125,7 +125,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
- -1.5708
- 0
- 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource
version: 1.0.0
nest_96_wellplate_200ul_flat:
@@ -158,7 +158,7 @@ nest_96_wellplate_2ml_deep:
- -1.5708
- 0
- 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource
version: 1.0.0
thermoscientificnunc_96_wellplate_1300ul:

View File

@@ -69,7 +69,7 @@ opentrons_96_filtertiprack_1000ul:
- -1.5708
- 0
- 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource
version: 1.0.0
opentrons_96_filtertiprack_10ul:

View File

@@ -135,6 +135,7 @@ class BatteryState(TypedDict):
open_circuit_voltage: float
assembly_pressure: float
electrolyte_volume: float
pole_weight: float # 极片称重 (mg)
info: Optional[str] # 附加信息
@@ -179,6 +180,7 @@ class Battery(Container):
open_circuit_voltage=0.0,
assembly_pressure=0.0,
electrolyte_volume=0.0,
pole_weight=0.0,
info=None
)

View File

@@ -1035,7 +1035,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})")
# 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称去除PLR系统添加的后缀
# tracker.liquids 格式: [(物料名称, 数量), ...]
# tracker.liquids 格式: [(物料名称, 数量, 单位), ...]
material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶"
if hasattr(bottle, "tracker") and bottle.tracker.liquids:
# 如果有液体,使用液体的名称
@@ -1053,7 +1053,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
"typeId": bottle_type_info[1],
"code": bottle.code if hasattr(bottle, "code") else "",
"name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶"
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"x": bioyond_x,
"y": bioyond_y,
"z": 1,
@@ -1126,7 +1126,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
"barCode": "",
"name": material_name, # 使用物料名称而不是资源名称
"unit": default_unit, # 使用配置的单位或默认单位
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"Parameters": parameters_json # API 实际要求的字段(必需)
}

View File

@@ -4,6 +4,8 @@ import json
import threading
import time
import traceback
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
from typing import (
get_type_hints,
TypeVar,
@@ -78,6 +80,67 @@ if TYPE_CHECKING:
T = TypeVar("T")
class RclpyAsyncMutex:
"""rclpy executor 兼容的异步互斥锁
通过 executor.create_task 唤醒等待者,避免 timer 的 InvalidHandle 问题。
"""
def __init__(self, name: str = ""):
self._lock = threading.Lock()
self._acquired = False
self._queue: List[Future] = []
self._name = name
self._holder: Optional[str] = None
async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""):
"""获取锁。如果已被占用,则异步等待直到锁释放。"""
# t0 = time.time()
with self._lock:
# qlen = len(self._queue)
if not self._acquired:
self._acquired = True
self._holder = tag
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 获取锁 tag={tag} (无等待, queue=0)"
# )
return
waiter = Future()
self._queue.append(waiter)
# node.lab_logger().info(
# f"[Mutex:{self._name}] 等待锁 tag={tag} "
# f"(holder={self._holder}, queue={qlen + 1})"
# )
await waiter
# wait_ms = (time.time() - t0) * 1000
self._holder = tag
# node.lab_logger().info(
# f"[Mutex:{self._name}] 获取锁 tag={tag} (等了 {wait_ms:.0f}ms)"
# )
def release(self, node: "BaseROS2DeviceNode"):
"""释放锁,通过 executor task 唤醒下一个等待者。"""
with self._lock:
# old_holder = self._holder
if self._queue:
next_waiter = self._queue.pop(0)
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 唤醒下一个 (剩余 queue={len(self._queue)})"
# )
async def _wake():
if not next_waiter.done():
next_waiter.set_result(None)
rclpy.get_global_executor().create_task(_wake())
else:
self._acquired = False
self._holder = None
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 空闲"
# )
# 在线设备注册表
registered_devices: Dict[str, "DeviceInfoType"] = {}
@@ -355,6 +418,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}"
)
self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}")
# 创建资源管理客户端
self._resource_clients: Dict[str, Client] = {
"resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group),
@@ -378,15 +443,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return res
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
_cmd = _fast_loads(req.command)
_res_name = _cmd.get("resource", [{}])
_res_name = (_res_name[0].get("id", "?") if isinstance(_res_name, list) and _res_name
else _res_name.get("id", "?") if isinstance(_res_name, dict) else "?")
_ar_tag = f"{_res_name}"
# _t_enter = time.time()
# self.lab_logger().info(f"[AR:{_ar_tag}] 进入 append_resource")
await self._append_resource_lock.acquire(self, tag=_ar_tag)
# _t_locked = time.time()
try:
return await _append_resource_inner(req, res, _ar_tag)
# _t_done = time.time()
# self.lab_logger().info(
# f"[AR:{_ar_tag}] 完成 "
# f"等锁={(_t_locked - _t_enter) * 1000:.0f}ms "
# f"执行={(_t_done - _t_locked) * 1000:.0f}ms "
# f"总计={(_t_done - _t_enter) * 1000:.0f}ms"
# )
except Exception as _ex:
self.lab_logger().error(f"[AR:{_ar_tag}] 异常: {_ex}")
raise
finally:
self._append_resource_lock.release(self)
async def _append_resource_inner(req: SerialCommand_Request, res: SerialCommand_Response, _ar_tag: str = ""):
from pylabrobot.resources.deck import Deck
from pylabrobot.resources import Coordinate
from pylabrobot.resources import Plate
# 物料传输到对应的node节点
# _t0 = time.time()
client = self._resource_clients["c2s_update_resource_tree"]
request = SerialCommand.Request()
request2 = SerialCommand.Request()
command_json = json.loads(req.command)
command_json = _fast_loads(req.command)
namespace = command_json["namespace"]
bind_parent_id = command_json["bind_parent_id"]
edge_device_id = command_json["edge_device_id"]
@@ -439,7 +529,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
)
# noinspection PyUnresolvedReferences
request.command = json.dumps(
# _t1 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 准备完成 PLR转换+序列化 {((_t1 - _t0) * 1000):.0f}ms, 发送首次上传..."
# )
request.command = _fast_dumps_str(
{
"action": "add",
"data": {
@@ -450,7 +544,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
}
)
tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response)
# _t2 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 首次上传完成 {((_t2 - _t1) * 1000):.0f}ms"
# )
uuid_maps = _fast_loads(tree_response.response)
plr_instances = rts.to_plr_resources()
for plr_instance in plr_instances:
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
@@ -508,7 +606,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for input_well, liquid_type, liquid_volume, liquid_input_slot in zip(
input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
):
input_well.set_liquids([(liquid_type, liquid_volume, "uL")])
input_well.set_liquids([(liquid_type, liquid_volume, "ul")])
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
input_wells
).dump()
@@ -527,12 +625,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
Coordinate(location["x"], location["y"], location["z"]),
**other_calling_param,
)
# 调整了液体以及Deck之后要重新Assign
# noinspection PyUnresolvedReferences
# _t3 = time.time()
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
# _n_parent = len(rts_with_parent.all_nodes)
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
request.command = json.dumps(
request.command = _fast_dumps_str(
{
"action": "add",
"data": {
@@ -542,11 +641,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
},
}
)
# _t4 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 二次上传序列化 {_n_parent}节点 {((_t4 - _t3) * 1000):.0f}ms, 发送中..."
# )
tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response)
# _t5 = time.time()
uuid_maps = _fast_loads(tree_response.response)
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
# 这里created_resources不包含parent_resource
# self._lab_logger.info(
# f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms "
# f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms"
# )
# 发送给ResourceMeshManager
action_client = ActionClient(
self,
@@ -683,7 +789,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
)
# 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
if not response.response:
raise ValueError(f"查询资源 {resource_id} 失败:服务端返回空响应")
raw_data = json.loads(response.response)
if not raw_data:
raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空")
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
@@ -1132,7 +1242,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if uid is None:
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性无法转运")
target_uids.append(uid)
srv_address = f"/srv{target_device_id}/s2c_resource_tree"
_ns = target_device_id if target_device_id.startswith("/devices/") else f"/devices/{target_device_id.lstrip('/')}"
srv_address = f"/srv{_ns}/s2c_resource_tree"
sclient = self.create_client(SerialCommand, srv_address)
# 等待服务可用(设置超时)
if not sclient.wait_for_service(timeout_sec=5.0):
@@ -1182,7 +1293,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return False
time.sleep(0.05)
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
return None
return "转运完成"
def register_device(self):
"""向注册表中注册设备信息"""
@@ -1567,37 +1678,75 @@ class BaseROS2DeviceNode(Node, Generic[T]):
feedback_msg_types = action_type.Feedback.get_fields_and_field_types()
result_msg_types = action_type.Result.get_fields_and_field_types()
while future is not None and not future.done():
if goal_handle.is_cancel_requested:
self.lab_logger().info(f"取消动作: {action_name}")
future.cancel() # 尝试取消线程池中的任务
goal_handle.canceled()
return action_type.Result()
# 低频 feedback timer10s不阻塞完成检测
_feedback_timer = None
self._time_spent = time.time() - time_start
self._time_remaining = time_overall - self._time_spent
def _publish_feedback():
if future is not None and not future.done():
self._time_spent = time.time() - time_start
self._time_remaining = time_overall - self._time_spent
feedback_values = {}
for msg_name, attr_name in action_value_mapping["feedback"].items():
if hasattr(self.driver_instance, f"get_{attr_name}"):
method = getattr(self.driver_instance, f"get_{attr_name}")
if not asyncio.iscoroutinefunction(method):
feedback_values[msg_name] = method()
elif hasattr(self.driver_instance, attr_name):
feedback_values[msg_name] = getattr(self.driver_instance, attr_name)
if self._print_publish:
self.lab_logger().info(f"反馈: {feedback_values}")
feedback_msg = convert_to_ros_msg_with_mapping(
ros_msg_type=action_type.Feedback(),
obj=feedback_values,
value_mapping=action_value_mapping["feedback"],
)
goal_handle.publish_feedback(feedback_msg)
# 发布反馈
feedback_values = {}
for msg_name, attr_name in action_value_mapping["feedback"].items():
if hasattr(self.driver_instance, f"get_{attr_name}"):
method = getattr(self.driver_instance, f"get_{attr_name}")
if not asyncio.iscoroutinefunction(method):
feedback_values[msg_name] = method()
elif hasattr(self.driver_instance, attr_name):
feedback_values[msg_name] = getattr(self.driver_instance, attr_name)
if self._print_publish:
self.lab_logger().info(f"反馈: {feedback_values}")
feedback_msg = convert_to_ros_msg_with_mapping(
ros_msg_type=action_type.Feedback(),
obj=feedback_values,
value_mapping=action_value_mapping["feedback"],
if action_value_mapping.get("feedback"):
_fb_interval = action_value_mapping.get("feedback_interval", 0.5)
_feedback_timer = self.create_timer(
_fb_interval, _publish_feedback, callback_group=self.callback_group
)
goal_handle.publish_feedback(feedback_msg)
time.sleep(0.5)
# 等待 action 完成
if future is not None:
if isinstance(future, Task):
# rclpy Task直接 await完成瞬间唤醒
try:
_raw_result = await future
except Exception as e:
_raw_result = e
else:
# concurrent.futures.Future同步 action用 rclpy 兼容的轮询
_poll_future = Future()
def _on_sync_done(fut):
if not _poll_future.done():
_poll_future.set_result(None)
future.add_done_callback(_on_sync_done)
await _poll_future
try:
_raw_result = future.result()
except Exception as e:
_raw_result = e
# 确保 execution_error/success 被正确设置(不依赖 done callback 时序)
if isinstance(_raw_result, BaseException):
if not execution_error:
execution_error = traceback.format_exception(
type(_raw_result), _raw_result, _raw_result.__traceback__
)
execution_error = "".join(execution_error)
execution_success = False
action_return_value = _raw_result
elif not execution_error:
execution_success = True
action_return_value = _raw_result
# 清理 feedback timer
if _feedback_timer is not None:
_feedback_timer.cancel()
if future is not None and future.cancelled():
self.lab_logger().info(f"动作 {action_name} 已取消")
@@ -1606,8 +1755,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# self.lab_logger().info(f"动作执行完成: {action_name}")
del future
# 执行失败时跳过物料状态更新
if execution_error:
execution_success = False
# 向Host更新物料当前状态
if action_name not in ["create_resource_detailed", "create_resource"]:
if not execution_error and action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue
@@ -1663,7 +1816,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for attr_name in result_msg_types.keys():
if attr_name in ["success", "reached_goal"]:
setattr(result_msg, attr_name, True)
setattr(result_msg, attr_name, execution_success)
elif attr_name == "return_info":
setattr(
result_msg,
@@ -1769,7 +1922,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise ValueError("至少需要提供一个 UUID")
uuids_list = list(uuids)
future = self._resource_clients["c2s_update_resource_tree"].call_async(
future: Future = self._resource_clients["c2s_update_resource_tree"].call_async(
SerialCommand.Request(
command=json.dumps(
{
@@ -1795,6 +1948,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise Exception(f"资源查询返回空结果: {uuids_list}")
raw_data = json.loads(response.response)
if not raw_data:
raise Exception(f"资源原始查询返回空结果: {raw_data}")
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
@@ -1816,10 +1971,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
mapped_plr_resources = []
for uuid in uuids_list:
found = None
for plr_resource in figured_resources:
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
mapped_plr_resources.append(r)
break
if r is not None:
found = r
break
if found is None:
raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源")
mapped_plr_resources.append(found)
return mapped_plr_resources
@@ -1912,16 +2072,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
)
async def _convert_resource_async(self, resource_data: Dict[str, Any]):
"""异步转换资源数据为实例"""
# 使用封装的get_resource_with_dir方法获取PLR资源
plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True)
async def _convert_resource_async(self, resource_data: "ResourceDictType"):
"""异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询"""
unilabos_uuid = resource_data.get("uuid")
if unilabos_uuid:
resource_tree = await self.get_resource([unilabos_uuid], with_children=True)
plr_resources = resource_tree.to_plr_resources()
if plr_resources:
plr_resource = plr_resources[0]
else:
raise ValueError(f"通过 uuid={unilabos_uuid} 查询资源为空")
else:
res_id = resource_data.get("id") or resource_data.get("name", "")
if not res_id:
raise ValueError(f"资源数据缺少 uuid 和 id: {list(resource_data.keys())}")
plr_resource = await self.get_resource_with_dir(resource_id=res_id, with_children=True)
# 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
if len(res) == 0:
# todo: 后续通过decoration来区分减少warning
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例")
return plr_resource
elif len(res) == 1:
return res[0]
@@ -2169,4 +2340,4 @@ class DeviceInfoType(TypedDict):
status_publishers: Dict[str, PropertyPublisher]
actions: Dict[str, ActionServer]
hardware_interface: Dict[str, Any]
base_node_instance: BaseROS2DeviceNode
base_node_instance: BaseROS2DeviceNode

View File

@@ -4,6 +4,8 @@ import threading
import time
import traceback
import uuid
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
@@ -618,22 +620,17 @@ class HostNode(BaseROS2DeviceNode):
}
)
]
response: List[str] = await self.create_resource_detailed(
resources, device_ids, bind_parent_id, bind_location, other_calling_param
)
try:
assert len(response) == 1, "Create Resource应当只返回一个结果"
for i in response:
res = json.loads(i)
if "suc" in res:
raise ValueError(res.get("error"))
return res
except Exception as ex:
pass
_n = "\n"
raise ValueError(f"创建资源时失败!\n{_n.join(response)}")
assert len(response) == 1, "Create Resource应当只返回一个结果"
for i in response:
res = json.loads(i)
if "suc" in res and not res["suc"]:
raise ValueError(res.get("error", "未知错误"))
return res
raise ValueError(f"创建资源时失败!响应为空")
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
"""
@@ -1168,7 +1165,7 @@ class HostNode(BaseROS2DeviceNode):
else:
physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {}))
response.response = json.dumps(uuid_mapping) if success else "FAILED"
response.response = _fast_dumps_str(uuid_mapping) if success else "FAILED"
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
@@ -1178,6 +1175,7 @@ class HostNode(BaseROS2DeviceNode):
resource_response = http_client.resource_tree_get(uuid_list, with_children)
response.response = json.dumps(resource_response)
self.lab_logger().trace(f"[Host Node-Resource] Resource tree get request callback {response.response}")
async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
"""
@@ -1230,9 +1228,26 @@ class HostNode(BaseROS2DeviceNode):
"""
try:
# 解析请求数据
data = json.loads(request.command)
data = _fast_loads(request.command)
action = data["action"]
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received")
inner = data.get("data", {})
if action == "add":
mount_uuid = inner.get("mount_uuid", "?")[:8] if isinstance(inner, dict) else "?"
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
source = f"mount={mount_uuid}.. nodes≈{node_count}"
elif action in ("get", "remove"):
uid_list = inner.get("data", inner) if isinstance(inner, dict) else inner
source = f"uuids={len(uid_list) if isinstance(uid_list, list) else '?'}"
elif action == "update":
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
source = f"nodes≈{node_count}"
else:
source = ""
self.lab_logger().info(
f"[Host Node-Resource] Resource tree {action} request received ({source})"
)
data = data["data"]
if action == "add":
await self._resource_tree_action_add_callback(data, response)

View File

@@ -22,6 +22,447 @@
"arm_state": "idle",
"message": "工作台就绪"
}
},
{
"id": "PRCXI",
"name": "PRCXI",
"type": "device",
"class": "liquid_handler.prcxi",
"parent": "",
"pose": {
"size": {
"width": 562,
"height": 394,
"depth": 0
}
},
"config": {
"axis": "Left",
"deck": {
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
"_resource_child_name": "PRCXI_Deck"
},
"host": "10.20.30.184",
"port": 9999,
"debug": true,
"setup": true,
"is_9320": true,
"timeout": 10,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"simulator": true,
"channel_num": 2
},
"data": {
"reset_ok": true
},
"schema": {},
"description": "",
"model": null,
"position": {
"x": 0,
"y": 240,
"z": 0
}
},
{
"id": "PRCXI_Deck",
"name": "PRCXI_Deck",
"children": [],
"parent": "PRCXI",
"type": "deck",
"class": "",
"position": {
"x": 10,
"y": 10,
"z": 0
},
"config": {
"type": "PRCXI9300Deck",
"size_x": 542,
"size_y": 374,
"size_z": 0,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "deck",
"barcode": null,
"preferred_pickup_location": null,
"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": [
"container",
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T2",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T3",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T4",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T5",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T6",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T7",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T9",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T10",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T11",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T14",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T15",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T16",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
}
]
},
"data": {}
}
],
"links": []

View File

@@ -17,6 +17,14 @@ try:
default=json_default,
)
def fast_loads(data) -> dict:
"""JSON 反序列化,优先使用 orjson。接受 str / bytes。"""
return orjson.loads(data)
def fast_dumps_str(obj, **kwargs) -> str:
"""JSON 序列化为 str优先使用 orjson。用于需要 str 而非 bytes 的场景(如 ROS msg"""
return orjson.dumps(obj, option=orjson.OPT_NON_STR_KEYS, default=json_default).decode("utf-8")
def normalize_json(info: dict) -> dict:
"""经 JSON 序列化/反序列化一轮来清理非标准类型。"""
return orjson.loads(orjson.dumps(info, default=json_default))
@@ -29,6 +37,14 @@ except ImportError:
def fast_dumps_pretty(obj, **kwargs) -> bytes: # type: ignore[misc]
return json.dumps(obj, indent=2, ensure_ascii=False, cls=TypeEncoder).encode("utf-8")
def fast_loads(data) -> dict: # type: ignore[misc]
if isinstance(data, bytes):
data = data.decode("utf-8")
return json.loads(data)
def fast_dumps_str(obj, **kwargs) -> str: # type: ignore[misc]
return json.dumps(obj, ensure_ascii=False, cls=TypeEncoder)
def normalize_json(info: dict) -> dict: # type: ignore[misc]
return json.loads(json.dumps(info, ensure_ascii=False, cls=TypeEncoder))