diff --git a/.conda/base/recipe.yaml b/.conda/base/recipe.yaml index a63dda77..e37e3ab1 100644 --- a/.conda/base/recipe.yaml +++ b/.conda/base/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos - version: 0.10.19 + version: 0.11.1 source: path: ../../unilabos @@ -54,7 +54,7 @@ requirements: - pymodbus - matplotlib - pylibftdi - - uni-lab::unilabos-env ==0.10.19 + - uni-lab::unilabos-env ==0.11.1 about: repository: https://github.com/deepmodeling/Uni-Lab-OS diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml index e9fd3e24..13ee9f88 100644 --- a/.conda/environment/recipe.yaml +++ b/.conda/environment/recipe.yaml @@ -2,7 +2,7 @@ package: name: unilabos-env - version: 0.10.19 + version: 0.11.1 build: noarch: generic diff --git a/.conda/full/recipe.yaml b/.conda/full/recipe.yaml index ab0e0c9f..7202ad9f 100644 --- a/.conda/full/recipe.yaml +++ b/.conda/full/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos-full - version: 0.10.19 + version: 0.11.1 build: noarch: generic @@ -11,7 +11,7 @@ build: requirements: run: # Base unilabos package (includes unilabos-env) - - uni-lab::unilabos ==0.10.19 + - uni-lab::unilabos ==0.11.1 # Documentation tools - sphinx - sphinx_rtd_theme diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md index 61b6252e..522c05bf 100644 --- a/.cursor/skills/add-device/SKILL.md +++ b/.cursor/skills/add-device/SKILL.md @@ -71,6 +71,22 @@ from unilabos.registry.decorators import action - `_` 开头的方法 → 不扫描 - `@not_action` 标记的方法 → 排除 +### 参数文档 → JSON Schema 元数据 + +在 `__init__` 和 action 方法 docstring 的 `Args:` 小节里,使用以下格式生成入参 schema 的显示信息: + +```python +""" +Args: + param[显示名称]: 参数说明,会写入 JSON Schema 的 description。 +""" +``` + +- `param[显示名称]` 的显示名称会写入 goal property 的 `title`。 +- `:` 后面的说明会写入 goal property 的 `description`。 +- 如果只写 `param: 参数说明`,`title` 会兜底为字段名,`description` 使用参数说明。 +- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>` 和 `description=""`,但新设备应优先写清楚显示名和说明。 + ### @topic_config — 状态属性配置 ```python @@ -105,13 +121,27 @@ import logging from typing import Any, Dict, Optional from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode -from unilabos.registry.decorators import device, action, topic_config, not_action +from unilabos.registry.decorators import action, device, not_action, topic_config -@device(id="my_device", category=["my_category"], description="设备描述") +@device( + id="my_device", + category=["my_category"], + description="设备描述", + display_name="设备显示名", +) class MyDevice: + """设备类说明。""" + _ros_node: BaseROS2DeviceNode def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + """ + 初始化设备。 + + Args: + device_id[设备ID]: 设备实例 ID,默认使用 my_device。 + config[设备配置]: 设备启动配置。 + """ self.device_id = device_id or "my_device" self.config = config or {} self.logger = logging.getLogger(f"MyDevice.{self.device_id}") @@ -133,7 +163,13 @@ class MyDevice: @action(description="执行操作") def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]: - """带 @action 装饰器 → 注册为 'my_action' 动作""" + """ + 带 @action 装饰器 → 注册为 'my_action' 动作。 + + Args: + param[操作数值]: 操作使用的数值参数。 + name[操作名称]: 操作名称或备注。 + """ return {"success": True} def get_info(self) -> Dict[str, Any]: diff --git a/.cursor/skills/batch-insert-reagent/SKILL.md b/.cursor/skills/batch-insert-reagent/SKILL.md index cd946cc3..3df13fd3 100644 --- a/.cursor/skills/batch-insert-reagent/SKILL.md +++ b/.cursor/skills/batch-insert-reagent/SKILL.md @@ -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 " @@ -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 应根据化学知识推断或提示用户补充。 diff --git a/.cursor/skills/batch-submit-experiment/SKILL.md b/.cursor/skills/batch-submit-experiment/SKILL.md index de6fed5e..0a368ba3 100644 --- a/.cursor/skills/batch-submit-experiment/SKILL.md +++ b/.cursor/skills/batch-submit-experiment/SKILL.md @@ -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 ` 是 **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())" # 方式二:手动计算 # base64(ak:sk) → Authorization: Lab +# ⚠️ 这里的 "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 工具搜索文件**,不要直接猜测路径: ``` -/unilabos_data/req_device_registry_upload.json -/req_device_registry_upload.json +Glob: **/req_device_registry_upload.json ``` -也可直接 Glob 搜索:`**/req_device_registry_upload.json` +常见位置(仅供参考,以 Glob 实际结果为准): +- `/unilabos_data/req_device_registry_upload.json` +- `/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": "", - "project_uuid": "", - "workflow_uuid": "", - "name": "<实验名称>", - "node_params": [ + "lab_uuid": "", + "project_uuid": "", + "workflow_uuid": "", + "name": "<实验名称>", + "node_params": [ + { + "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], + "datas": [ { - "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], - "datas": [ - { - "node_uuid": "", - "param": {}, - "sample_params": [ - { - "container_uuid": "<容器UUID>", - "sample_value": { - "liquid_names": "<液体名称>", - "volumes": 1000 - } - } - ] - } - ] + "node_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 数组,无样品时传 `[]` | -| `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"] } diff --git a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py index f22b37e8..a6cbea86 100644 --- a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py +++ b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py @@ -7,7 +7,7 @@ 选项: --auth Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀) - --base API 基础 URL(如 https://uni-lab.test.bohrium.com) + --base API 基础 URL(如 https://leap-lab.test.bohrium.com) --workflow-uuid 目标 workflow 的 UUID --registry 本地注册表文件路径(默认自动搜索) --rounds 实验轮次数(默认 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 """ diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index 20cd2f33..c4fc7a10 100644 --- a/.cursor/skills/create-device-skill/SKILL.md +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config 决定 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 **推断 working_dir**(即 `unilabos_data` 所在目录): -| 条件 | working_dir 取值 | -|------|------------------| +| 条件 | working_dir 取值 | +| -------------------- | -------------------------------------------------------- | | 传了 `--working_dir` | `/unilabos_data/`(若子目录已存在则直接用) | -| 仅传了 `--config` | `/unilabos_data/` | -| 都没传 | `<当前工作目录>/unilabos_data/` | +| 仅传了 `--config` | `/unilabos_data/` | +| 都没传 | `<当前工作目录>/unilabos_data/` | **按优先级搜索文件**: @@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config 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 ] ./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 ] ./ski ### Step 3 — 写 action-index.md -按模板为每个 action 写条目: +按模板为每个 action 写条目(**必须包含 `action_type`**): ```markdown ### `` <用途描述(一句话)> +- **action_type**: `<从 actions/.json 的 type 字段获取>` - **Schema**: [`actions/.json`](actions/.json) - **核心参数**: `param1`, `param2`(从 schema.required 获取) - **可选参数**: `param3`, `param4` @@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry ] ./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 ] ./ski ### Step 4 — 写 SKILL.md 直接复用 `unilab-device-api` 的 API 模板,修改: + - 设备名称 - Action 数量 - 目录列表 @@ -172,42 +159,77 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski - **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab `(不要硬编码 `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: "", node_template_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: "", node_template_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/.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=" -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 '' +``` + +请求体: + +```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 '' +``` + +请求体: + +```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 | 用户指定 | 更新扩展数据 | + +> 只传需要更新的字段,未传的字段保持不变。 ## 最终目录结构 diff --git a/.cursor/skills/submit-agent-result/SKILL.md b/.cursor/skills/submit-agent-result/SKILL.md index 18923711..b94a0aaf 100644 --- a/.cursor/skills/submit-agent-result/SKILL.md +++ b/.cursor/skills/submit-agent-result/SKILL.md @@ -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 ` 是 **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())" ``` -输出即为 token 值,拼接为 `Authorization: Lab `。 +输出即为 token 值,拼接为 `Authorization: Lab `(`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 ] ``` -| 参数 | 必选 | 说明 | -|------|------|------| -| `--notebook-uuid` | 是 | 目标 notebook UUID | -| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) | -| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) | -| `--base` | 提交时必选 | API base URL | -| `--submit` | 否 | 加上此标志则直接提交到云端 | -| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) | +| 参数 | 必选 | 说明 | +| ----------------- | ---------- | ----------------------------------------------- | +| `--notebook-uuid` | 是 | 目标 notebook UUID | +| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) | +| `--auth` | 提交时必选 | Lab token(base64(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 ` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 +本指南统一使用 `Authorization: Lab ` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 402edc26..698344bf 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -38,7 +38,7 @@ jobs: - name: Install ROS dependencies, uv and unilabos-msgs run: | echo Installing ROS dependencies... - mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y + mamba install -n check-env --override-channels -c robostack-staging -c conda-forge -c uni-lab conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -y - name: Install pip dependencies and unilabos run: | diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index ed45db9d..3da148dd 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -1,6 +1,10 @@ name: Build Conda-Pack Environment on: + # 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack + workflow_run: + workflows: ["UniLabOS Conda Build"] + types: [completed] workflow_dispatch: inputs: branch: @@ -21,6 +25,16 @@ on: jobs: build-conda-pack: + if: | + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_run' + ) + env: + BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }} + PACKAGE_REF: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref_name }} strategy: fail-fast: false matrix: @@ -54,7 +68,9 @@ jobs: id: should_build shell: bash run: | - if [[ -z "${{ github.event.inputs.platforms }}" ]]; then + if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then + echo "should_build=true" >> $GITHUB_OUTPUT + elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then echo "should_build=true" >> $GITHUB_OUTPUT elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then echo "should_build=true" >> $GITHUB_OUTPUT @@ -65,7 +81,7 @@ jobs: - uses: actions/checkout@v6 if: steps.should_build.outputs.should_build == 'true' with: - ref: ${{ github.event.inputs.branch }} + ref: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 - name: Setup Miniforge (with mamba) @@ -75,7 +91,7 @@ jobs: miniforge-version: latest use-mamba: true python-version: '3.11.14' - channels: conda-forge,robostack-staging,uni-lab,defaults + channels: conda-forge,robostack-staging,uni-lab channel-priority: flexible activate-environment: unilab auto-update-conda: false @@ -86,13 +102,13 @@ jobs: run: | echo Installing unilabos and dependencies to unilab environment... echo Using mamba for faster and more reliable dependency resolution... - echo Build full: ${{ github.event.inputs.build_full }} - if "${{ github.event.inputs.build_full }}"=="true" ( + echo Build full: ${{ env.BUILD_FULL }} + if "${{ env.BUILD_FULL }}"=="true" ( echo Installing unilabos-full ^(complete package^)... - mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y ) else ( echo Installing unilabos ^(minimal package^)... - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y ) - name: Install conda-pack, unilabos and dependencies (Unix) @@ -101,13 +117,13 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - echo "Build full: ${{ github.event.inputs.build_full }}" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + echo "Build full: ${{ env.BUILD_FULL }}" + if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then echo "Installing unilabos-full (complete package)..." - mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y else echo "Installing unilabos (minimal package)..." - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y fi - name: Get latest ros-humble-unilabos-msgs version (Windows) @@ -134,27 +150,27 @@ jobs: if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Checking for available ros-humble-unilabos-msgs versions... - mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed + mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed echo. echo Updating ros-humble-unilabos-msgs to latest version... - mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version + mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo Already at latest version - name: Check for newer ros-humble-unilabos-msgs (Unix) if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' shell: bash run: | echo "Checking for available ros-humble-unilabos-msgs versions..." - mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed" + mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed" echo "" echo "Updating ros-humble-unilabos-msgs to latest version..." - mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version" + mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo "Already at latest version" - name: Install latest unilabos from source (Windows) if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Uninstalling existing unilabos... mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip - echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})... + echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})... mamba run -n unilab pip install . echo Verifying installation... mamba run -n unilab pip show unilabos @@ -165,7 +181,7 @@ jobs: run: | echo "Uninstalling existing unilabos..." mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip" - echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..." + echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..." mamba run -n unilab pip install . echo "Verifying installation..." mamba run -n unilab pip show unilabos @@ -226,7 +242,9 @@ jobs: if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Packing unilab environment with conda-pack... - mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files + for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i" + echo Packing environment at: %UNILAB_PREFIX% + mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files echo Pack file created: dir unilab-env-${{ matrix.platform }}.tar.gz @@ -235,8 +253,9 @@ jobs: shell: bash run: | echo "Packing unilab environment with conda-pack..." - mamba install conda-pack -c conda-forge -y - conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files + UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')" + echo "Packing environment at: $UNILAB_PREFIX" + mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files echo "Pack file created:" ls -lh unilab-env-${{ matrix.platform }}.tar.gz @@ -267,7 +286,7 @@ jobs: rem Create README using Python script echo Creating: README.txt - python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt + python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt echo. echo Distribution package contents: @@ -303,7 +322,7 @@ jobs: # Create README using Python script echo "Creating: README.txt" - python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt + python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt echo "" echo "Distribution package contents:" @@ -314,7 +333,7 @@ jobs: if: steps.should_build.outputs.should_build == 'true' uses: actions/upload-artifact@v6 with: - name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} + name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }} path: dist-package/ retention-days: 90 if-no-files-found: error @@ -326,9 +345,9 @@ jobs: echo Build Summary echo ========================================== echo Platform: ${{ matrix.platform }} - echo Branch: ${{ github.event.inputs.branch }} + echo Branch: ${{ env.PACKAGE_REF }} echo Python version: 3.11.14 - if "${{ github.event.inputs.build_full }}"=="true" ( + if "${{ env.BUILD_FULL }}"=="true" ( echo Package: unilabos-full ^(complete^) ) else ( echo Package: unilabos ^(minimal^) @@ -337,7 +356,7 @@ jobs: echo Distribution package contents: dir dist-package echo. - echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} + echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }} echo. echo After download, extract the ZIP and run: echo install_unilab.bat @@ -351,9 +370,9 @@ jobs: echo "Build Summary" echo "==========================================" echo "Platform: ${{ matrix.platform }}" - echo "Branch: ${{ github.event.inputs.branch }}" + echo "Branch: ${{ env.PACKAGE_REF }}" echo "Python version: 3.11.14" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then echo "Package: unilabos-full (complete)" else echo "Package: unilabos (minimal)" @@ -362,7 +381,7 @@ jobs: echo "Distribution package contents:" ls -lh dist-package/ echo "" - echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}" + echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}" echo "" echo "After download:" echo " install_unilab.sh" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index f3ac4d11..a3ca6469 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -56,7 +56,7 @@ jobs: miniforge-version: latest use-mamba: true python-version: '3.11.14' - channels: conda-forge,robostack-staging,uni-lab,defaults + channels: conda-forge,robostack-staging,uni-lab channel-priority: flexible activate-environment: unilab auto-update-conda: false @@ -66,7 +66,7 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y - name: Install latest unilabos from source run: | diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 4e1cf4f7..1c5dd757 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -10,6 +10,9 @@ on: # 支持 tag 推送(不依赖 CI Check) push: tags: ['v*'] + # GitHub Release 发布时自动构建并上传 + release: + types: [published] # 手动触发 workflow_dispatch: inputs: @@ -80,7 +83,7 @@ jobs: - uses: actions/checkout@v6 with: # 如果是 workflow_run 触发,使用触发 CI Check 的 commit - ref: ${{ github.event.workflow_run.head_sha || github.ref }} + ref: ${{ github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }} fetch-depth: 0 - name: Check if platform should be built @@ -96,12 +99,13 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - name: Setup Miniconda + - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: 'latest' - channels: conda-forge,robostack-staging,defaults + miniforge-version: latest + use-mamba: true + channels: conda-forge,robostack-staging channel-priority: strict activate-environment: build-env auto-update-conda: false @@ -110,7 +114,7 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - conda install -c conda-forge rattler-build anaconda-client + mamba install --override-channels -c conda-forge rattler-build anaconda-client -y - name: Show environment info if: steps.should_build.outputs.should_build == 'true' @@ -157,7 +161,13 @@ jobs: retention-days: 30 - name: Upload to Anaconda.org (unilab organization) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'release' || + startsWith(github.ref, 'refs/tags/') || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | for package in $(find ./output -name "*.conda"); do echo "Uploading $package to unilab organization..." diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index d116a67e..11025543 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -1,14 +1,10 @@ name: UniLabOS Conda Build on: - # 在 CI Check 成功后自动触发 + # 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发 workflow_run: - workflows: ["CI Check"] + workflows: ["Multi-Platform Conda Build"] types: [completed] - branches: [main, dev] - # 标签推送时直接触发(发布版本) - push: - tags: ['v*'] # 手动触发 workflow_dispatch: inputs: @@ -33,30 +29,30 @@ on: type: boolean jobs: - # 等待 CI Check 完成的 job (仅用于 workflow_run 触发) - wait-for-ci: + # 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发) + wait-for-upstream: runs-on: ubuntu-latest if: github.event_name == 'workflow_run' outputs: should_continue: ${{ steps.check.outputs.should_continue }} steps: - - name: Check CI status + - name: Check upstream workflow status id: check run: | - if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then + if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then echo "should_continue=true" >> $GITHUB_OUTPUT - echo "CI Check passed, proceeding with build" + echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build" else echo "should_continue=false" >> $GITHUB_OUTPUT - echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build" + echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build" fi build: - needs: [wait-for-ci] - # 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式 + needs: [wait-for-upstream] + # 运行条件:workflow_run 触发且上游成功,或者手动触发 if: | always() && - (needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true') + (needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true') strategy: fail-fast: false matrix: @@ -79,7 +75,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - # 如果是 workflow_run 触发,使用触发 CI Check 的 commit + # 如果是 workflow_run 触发,使用上游 conda 包构建的 commit ref: ${{ github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 @@ -96,12 +92,13 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - name: Setup Miniconda + - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: 'latest' - channels: conda-forge,robostack-staging,uni-lab,defaults + miniforge-version: latest + use-mamba: true + channels: conda-forge,robostack-staging,uni-lab channel-priority: strict activate-environment: build-env auto-update-conda: false @@ -110,7 +107,7 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - conda install -c conda-forge rattler-build anaconda-client + mamba install --override-channels -c conda-forge rattler-build anaconda-client -y - name: Show environment info if: steps.should_build.outputs.should_build == 'true' @@ -119,11 +116,11 @@ jobs: conda list | grep -E "(rattler-build|anaconda-client)" echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" - echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}" + echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" echo "Building packages:" echo " - unilabos-env (environment dependencies)" echo " - unilabos (with pip package)" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then echo " - unilabos-full (complete package)" fi @@ -134,7 +131,12 @@ jobs: rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge - name: Upload unilabos-env to Anaconda.org (if enabled) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'workflow_run' || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | echo "Uploading unilabos-env to uni-lab organization..." for package in $(find ./output -name "unilabos-env*.conda"); do @@ -149,7 +151,12 @@ jobs: rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos to Anaconda.org (if enabled) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'workflow_run' || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | echo "Uploading unilabos to uni-lab organization..." for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do @@ -159,6 +166,7 @@ jobs: - name: Build unilabos-full - Only when explicitly requested if: | steps.should_build.outputs.should_build == 'true' && + github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' run: | echo "Building unilabos-full package on ${{ matrix.platform }}..." @@ -167,6 +175,7 @@ jobs: - name: Upload unilabos-full to Anaconda.org (if enabled) if: | steps.should_build.outputs.should_build == 'true' && + github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' && github.event.inputs.upload_to_anaconda == 'true' run: | diff --git a/docs/advanced_usage/configuration.md b/docs/advanced_usage/configuration.md index 3440044c..a885e06d 100644 --- a/docs/advanced_usage/configuration.md +++ b/docs/advanced_usage/configuration.md @@ -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 ``` **命令行方式(推荐):** diff --git a/docs/developer_guide/networking_overview.md b/docs/developer_guide/networking_overview.md index 19f16312..dc742235 100644 --- a/docs/developer_guide/networking_overview.md +++ b/docs/developer_guide/networking_overview.md @@ -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后查看日志 diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md index 499ee9ee..8e4fd357 100644 --- a/docs/user_guide/best_practice.md +++ b/docs/user_guide/best_practice.md @@ -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) --- diff --git a/docs/user_guide/graph_files.md b/docs/user_guide/graph_files.md index d6902829..f4951dde 100644 --- a/docs/user_guide/graph_files.md +++ b/docs/user_guide/graph_files.md @@ -626,7 +626,7 @@ unilab **云端图文件管理**: -1. 登录 https://uni-lab.bohrium.com +1. 登录 https://leap-lab.bohrium.com 2. 进入"设备配置" 3. 创建或编辑配置 4. 保存到云端 diff --git a/docs/user_guide/launch.md b/docs/user_guide/launch.md index 34caa5b9..4f8df40d 100644 --- a/docs/user_guide/launch.md +++ b/docs/user_guide/launch.md @@ -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` 参数 - 配置文件中包含正确的认证信息 diff --git a/recipes/conda_build_config.yaml b/recipes/conda_build_config.yaml index 8e95491c..c8915207 100644 --- a/recipes/conda_build_config.yaml +++ b/recipes/conda_build_config.yaml @@ -1,5 +1,5 @@ channel_sources: - - robostack,robostack-staging,conda-forge,defaults + - robostack,robostack-staging,conda-forge gazebo: - '11' diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index fc8a5ccf..0a59a2e9 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.19 + version: 0.11.1 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 91e07b24..f54f1eb7 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.19" + version: "0.11.1" source: path: ../.. diff --git a/setup.py b/setup.py index 7ca06f2e..4053388e 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.10.19', + version='0.11.1', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index eebdd757..fee46bd8 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.19" +__version__ = "0.11.1" diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 6c097682..8de9a75f 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -12,6 +12,15 @@ from typing import Dict, Any, List import networkx as nx import yaml +# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符 +# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃 +if sys.platform == "win32": + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] + except (AttributeError, OSError): + pass + # 首先添加项目根目录到路径 current_dir = os.path.dirname(os.path.abspath(__file__)) unilabos_dir = os.path.dirname(os.path.dirname(current_dir)) @@ -233,7 +242,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 +447,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 +562,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 diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 1dd056ae..527b813e 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -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}"}, diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 851ae320..4823a232 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -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, diff --git a/unilabos/config/config.py b/unilabos/config/config.py index b80d3b60..d8d000e2 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -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配置 diff --git a/unilabos/devices/virtual/virtual_multiway_valve.py b/unilabos/devices/virtual/virtual_multiway_valve.py index 1512f33d..b6a95ddf 100644 --- a/unilabos/devices/virtual/virtual_multiway_valve.py +++ b/unilabos/devices/virtual/virtual_multiway_valve.py @@ -2,6 +2,8 @@ import time import logging from typing import Union, Dict, Optional +from unilabos.registry.decorators import topic_config + class VirtualMultiwayValve: """ @@ -41,13 +43,11 @@ class VirtualMultiwayValve: def target_position(self) -> int: return self._target_position - def get_current_position(self) -> int: - """获取当前阀门位置 📍""" - return self._current_position - - def get_current_port(self) -> str: - """获取当前连接的端口名称 🔌""" - return self._current_position + @property + @topic_config() + def current_port(self) -> str: + """当前连接的端口名称 🔌""" + return self.port def set_position(self, command: Union[int, str]): """ @@ -169,12 +169,14 @@ class VirtualMultiwayValve: self._status = "Idle" self._valve_state = "Closed" - close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})" + close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})" self.logger.info(close_msg) return close_msg - def get_valve_position(self) -> int: - """获取阀门位置 - 兼容性方法 📍""" + @property + @topic_config() + def valve_position(self) -> int: + """阀门位置 📍""" return self._current_position def set_valve_position(self, command: Union[int, str]): @@ -229,19 +231,16 @@ class VirtualMultiwayValve: self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...") return self.set_to_pump_position() - def get_flow_path(self) -> str: - """获取当前流路路径描述 🌊""" - current_port = self.get_current_port() + @property + @topic_config() + def flow_path(self) -> str: + """当前流路路径描述 🌊""" if self._current_position == 0: - flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})" - else: - flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})" - - # 删除debug日志:self.logger.debug(f"🌊 当前流路: {flow_path}") - return flow_path + return f"🚰 转移泵已连接 (位置 {self._current_position})" + return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})" def __str__(self): - current_port = self.get_current_port() + current_port = self.current_port status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌" return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})" @@ -253,7 +252,7 @@ if __name__ == "__main__": print("🔄 === 虚拟九通阀门测试 === ✨") print(f"🏠 初始状态: {valve}") - print(f"🌊 当前流路: {valve.get_flow_path()}") + print(f"🌊 当前流路: {valve.flow_path}") # 切换到试剂瓶1(1号位) print(f"\n🔌 切换到1号位: {valve.set_position(1)}") diff --git a/unilabos/devices/virtual/virtual_stirrer.py b/unilabos/devices/virtual/virtual_stirrer.py index 8e95617f..5bd4b9e1 100644 --- a/unilabos/devices/virtual/virtual_stirrer.py +++ b/unilabos/devices/virtual/virtual_stirrer.py @@ -3,6 +3,7 @@ import logging import time as time_module from typing import Dict, Any +from unilabos.registry.decorators import topic_config from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class VirtualStirrer: @@ -314,9 +315,11 @@ class VirtualStirrer: def min_speed(self) -> float: return self._min_speed - def get_device_info(self) -> Dict[str, Any]: - """获取设备状态信息 📊""" - info = { + @property + @topic_config() + def device_info(self) -> Dict[str, Any]: + """设备状态快照信息 📊""" + return { "device_id": self.device_id, "status": self.status, "operation_mode": self.operation_mode, @@ -325,12 +328,9 @@ class VirtualStirrer: "is_stirring": self.is_stirring, "remaining_time": self.remaining_time, "max_speed": self._max_speed, - "min_speed": self._min_speed + "min_speed": self._min_speed, } - - # self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}") - return info - + def __str__(self): status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌" return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)" \ No newline at end of file diff --git a/unilabos/devices/virtual/virtual_transferpump.py b/unilabos/devices/virtual/virtual_transferpump.py index 2d3c9d8b..f7b24f18 100644 --- a/unilabos/devices/virtual/virtual_transferpump.py +++ b/unilabos/devices/virtual/virtual_transferpump.py @@ -4,6 +4,7 @@ from enum import Enum from typing import Union, Optional import logging +from unilabos.registry.decorators import topic_config from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode @@ -385,8 +386,10 @@ class VirtualTransferPump: """获取当前体积""" return self._current_volume - def get_remaining_capacity(self) -> float: - """获取剩余容量""" + @property + @topic_config() + def remaining_capacity(self) -> float: + """剩余容量 (ml)""" return self.max_volume - self._current_volume def is_empty(self) -> bool: diff --git a/unilabos/devices/virtual/workbench.py b/unilabos/devices/virtual/workbench.py index d67db398..80ae1bdf 100644 --- a/unilabos/devices/virtual/workbench.py +++ b/unilabos/devices/virtual/workbench.py @@ -14,19 +14,30 @@ Virtual Workbench Device - 模拟工作台设备 import logging import time -from typing import Dict, Any, Optional, List from dataclasses import dataclass from enum import Enum from threading import Lock, RLock +from typing import Any, Dict, List, Optional, cast from typing_extensions import TypedDict from unilabos.registry.decorators import ( - device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action + ActionInputHandle, + ActionOutputHandle, + DataSource, + NodeType, + action, + device, + not_action, + topic_config, +) +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, ) -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode -from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample - # ============ TypedDict 返回类型定义 ============ @@ -111,6 +122,7 @@ class HeatingStation: @device( id="virtual_workbench", + display_name="虚拟工作台", category=["virtual_device"], description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing", ) @@ -136,7 +148,19 @@ class VirtualWorkbench: HEATING_TIME: float = 60.0 # 加热时间(秒) NUM_HEATING_STATIONS: int = 3 # 加热台数量 - def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, + device_id: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, + **kwargs, + ): + """ + 初始化虚拟工作台。 + + Args: + device_id[设备ID]: 工作台设备实例 ID,默认使用 virtual_workbench。 + config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。 + """ # 处理可能的不同调用方式 if device_id is None and "id" in kwargs: device_id = kwargs.pop("id") @@ -150,9 +174,13 @@ class VirtualWorkbench: self.data: Dict[str, Any] = {} # 从config中获取可配置参数 - self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)) + self.ARM_OPERATION_TIME = float( + self.config.get("arm_operation_time", self.ARM_OPERATION_TIME) + ) self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME)) - self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)) + self.NUM_HEATING_STATIONS = int( + self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS) + ) # 机械臂状态和锁 self._arm_lock = Lock() @@ -161,7 +189,8 @@ class VirtualWorkbench: # 加热台状态 self._heating_stations: Dict[int, HeatingStation] = { - i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1) + i: HeatingStation(station_id=i) + for i in range(1, self.NUM_HEATING_STATIONS + 1) } self._stations_lock = RLock() @@ -290,20 +319,292 @@ 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: + """ + 人工确认资源转移和扣电测试参数。 + + Args: + resource[待转移资源]: 需要人工确认的资源列表。 + target_device[目标设备]: 资源要转移到的目标设备 ID。 + mount_resource[目标孔位]: 资源要挂载到的目标孔位列表。 + collector_mass[极流体质量]: 每个样品对应的极流体质量。 + active_material[活性物质含量]: 每个样品对应的活性物质含量。 + capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。 + battery_system[电池体系]: 每个样品对应的电池体系名称。 + timeout_seconds[超时时间]: 人工确认超时时间,单位秒。 + assignee_user_ids[确认人]: 指定处理人工确认任务的用户 ID 列表。 + + Note: + 修改的结果无效,是只读的。 + """ + resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump() + mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump() + kwargs.update(locals()) + kwargs.pop("kwargs") + kwargs.pop("self") + kwargs["resource"] = resource_tree + kwargs["mount_resource"] = mount_resource_tree + kwargs.pop("resource_tree") + kwargs.pop("mount_resource_tree") + 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], + ): + """ + 转移资源到目标设备。 + + Args: + resource[待转移资源]: 待转移的资源列表。 + target_device[目标设备]: 接收资源的目标设备 ID。 + mount_resource[目标孔位]: 目标设备上的挂载孔位列表。 + """ + 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], + ): + """ + 启动扣电测试。 + + Args: + resource[待测试资源]: 需要进行扣电测试的资源列表。 + mount_resource[测试孔位]: 扣电测试使用的目标孔位列表。 + collector_mass[极流体质量]: 每个样品对应的极流体质量。 + active_material[活性物质含量]: 每个样品对应的活性物质含量。 + capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。 + battery_system[电池体系]: 每个样品对应的电池体系名称。 + """ + 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供后续节点使用", handles=[ - ActionOutputHandle(key="channel_1", data_type="workbench_material", - label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_2", data_type="workbench_material", - label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_3", data_type="workbench_material", - label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_4", data_type="workbench_material", - label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_5", data_type="workbench_material", - label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501 ], ) def prepare_materials( @@ -316,6 +617,9 @@ class VirtualWorkbench: 作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。 输出5个handle (material_1 ~ material_5), 分别对应实验1~5。 + + Args: + count[物料数量]: 要生成的物料数量,默认生成 5 个。 """ materials = [i for i in range(1, count + 1)] @@ -336,7 +640,11 @@ class VirtualWorkbench: LabSample( sample_uuid=sample_uuid, oss_path="", - extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}), + extra=( + {"material_uuid": content} + if isinstance(content, str) + else (content.serialize() if content else {}) + ), ) for sample_uuid, content in sample_uuids.items() ], @@ -346,12 +654,27 @@ class VirtualWorkbench: auto_prefix=True, description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID", handles=[ - ActionInputHandle(key="material_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), - ActionOutputHandle(key="heating_station_output", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="material_number_output", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), + ActionInputHandle( + key="material_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="heating_station_output", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="material_number_output", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.EXECUTOR, + ), ], ) def move_to_heating_station( @@ -363,6 +686,9 @@ class VirtualWorkbench: 将物料从An位置移动到加热台 多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台 + + Args: + material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。 """ material_id = f"A{material_number}" task_desc = f"移动{material_id}到加热台" @@ -425,7 +751,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -448,7 +775,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -460,14 +788,34 @@ class VirtualWorkbench: always_free=True, description="启动指定加热台的加热程序", handles=[ - ActionInputHandle(key="station_id_input", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), - ActionInputHandle(key="material_number_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), - ActionOutputHandle(key="heating_done_station", data_type="workbench_station", - label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="heating_done_material", data_type="workbench_material", - label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), + ActionInputHandle( + key="station_id_input", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="material_number_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="heating_done_station", + data_type="workbench_station", + label="加热完成-加热台ID", + data_key="station_id", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="heating_done_material", + data_type="workbench_material", + label="加热完成-物料编号", + data_key="material_number", + data_source=DataSource.EXECUTOR, + ), ], ) def start_heating( @@ -478,6 +826,10 @@ class VirtualWorkbench: ) -> StartHeatingResult: """ 启动指定加热台的加热程序 + + Args: + station_id[加热台ID]: 要启动加热的加热台编号。 + material_number[物料编号]: 当前加热台上的物料编号。 """ self.logger.info(f"[加热台{station_id}] 开始加热") @@ -494,7 +846,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -517,7 +870,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -537,7 +891,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -577,7 +932,9 @@ class VirtualWorkbench: self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%") if time.time() - last_countdown_log >= 5.0: - self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s") + self.logger.info( + f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s" + ) last_countdown_log = time.time() if elapsed >= self.HEATING_TIME: @@ -594,7 +951,9 @@ class VirtualWorkbench: self._active_tasks[material_id]["status"] = "heating_completed" self._update_data_status(f"加热台{station_id}加热完成") - self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)") + self.logger.info( + f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)" + ) return { "success": True, @@ -608,7 +967,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -619,10 +979,20 @@ class VirtualWorkbench: auto_prefix=True, description="将物料从加热台移动到输出位置Cn", handles=[ - ActionInputHandle(key="output_station_input", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), - ActionInputHandle(key="output_material_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), + ActionInputHandle( + key="output_station_input", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="output_material_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), ], ) def move_to_output( @@ -633,6 +1003,10 @@ class VirtualWorkbench: ) -> MoveToOutputResult: """ 将物料从加热台移动到输出位置Cn + + Args: + station_id[加热台ID]: 已完成加热的加热台编号。 + material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。 """ output_number = material_number @@ -649,7 +1023,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -673,7 +1048,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -693,7 +1069,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -775,7 +1152,8 @@ class VirtualWorkbench: oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() diff --git a/unilabos/registry/ast_registry_scanner.py b/unilabos/registry/ast_registry_scanner.py index 80aba3e2..53071499 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -32,7 +32,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union MAX_SCAN_DEPTH = 10 # 最大目录递归深度 MAX_SCAN_FILES = 1000 # 最大扫描文件数量 -_CACHE_VERSION = 1 # 缓存格式版本号,格式变更时递增 +_CACHE_VERSION = 2 # 缓存格式版本号,格式变更时递增 # 合法的装饰器来源模块 _REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators" @@ -258,8 +258,6 @@ def scan_directory( } - - # --------------------------------------------------------------------------- # File-level parsing # --------------------------------------------------------------------------- @@ -361,6 +359,7 @@ def _parse_file( "actions": class_body.get("actions", {}), "status_properties": class_body.get("status_properties", {}), "init_params": class_body.get("init_params", []), + "init_docstring": class_body.get("init_docstring"), "auto_methods": class_body.get("auto_methods", {}), "import_map": import_map, } @@ -497,7 +496,6 @@ def _collect_imports(tree: ast.Module, module_path: str = "") -> Dict[str, str]: return import_map - # --------------------------------------------------------------------------- # Decorator finding & argument extraction # --------------------------------------------------------------------------- @@ -768,6 +766,7 @@ def _extract_class_body( "actions": {}, # method_name -> action_info "status_properties": {}, # prop_name -> status_info "init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...] + "init_docstring": None, "auto_methods": {}, # method_name -> method_info (no @action decorator) } @@ -780,6 +779,7 @@ def _extract_class_body( # --- __init__ --- if method_name == "__init__": result["init_params"] = _extract_method_params(item, import_map) + result["init_docstring"] = ast.get_docstring(item) continue # --- Skip private/dunder --- @@ -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) diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index 25a2e57f..1dffe169 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -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] diff --git a/unilabos/registry/devices/Qone_nmr.yaml b/unilabos/registry/devices/Qone_nmr.yaml index 5c5f1f8a..fd2761e4 100644 --- a/unilabos/registry/devices/Qone_nmr.yaml +++ b/unilabos/registry/devices/Qone_nmr.yaml @@ -51,14 +51,18 @@ Qone_nmr: properties: check_interval: default: 60 + description: 检查间隔时间(秒),默认60秒 type: string expected_count: default: 1 + description: 期望生成的.nmr文件数量,默认1个 type: string monitor_dir: + description: 要监督的目录路径,如果未指定则使用self.monitor_directory type: string stability_checks: default: 3 + description: 文件大小稳定性检查次数,默认3次 type: string required: [] type: object @@ -85,11 +89,14 @@ Qone_nmr: goal: properties: output_dir: + description: 输出目录(如果未指定,使用self.output_directory) type: string string_list: + description: 字符串列表 type: string txt_encoding: default: utf-8 + description: 文件编码 type: string required: - string_list @@ -151,6 +158,13 @@ Qone_nmr: additionalProperties: false properties: string: + description: '包含多个字符串的输入数据,支持两种格式: + + 1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30" + + 2. 换行分隔:如 "A 1 B 2 C 3 + + X 10 Y 20 Z 30"' type: string title: StrSingleInput_Goal type: object diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml index f57cd35c..6b2d1b17 100644 --- a/unilabos/registry/devices/bioyond_cell.yaml +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -491,14 +491,17 @@ bioyond_cell: goal: properties: material_names: + description: 物料名称列表;默认使用 [LiPF6, LiDFOB, DTD, LiFSI, LiPO2F2] items: type: string type: array type_id: default: 3a190ca0-b2f6-9aeb-8067-547e72c11469 + description: 物料类型ID type: string warehouse_name: default: 粉末加样头堆栈 + description: 目标仓库名(用于取位置信息) type: string required: [] type: object @@ -527,12 +530,16 @@ bioyond_cell: goal: properties: location_name_or_id: + description: 具体库位名称(如 A01)或库位 UUID,由用户指定。 type: string material_name: + description: 物料名称(会优先匹配配置模板)。 type: string type_id: + description: 物料类型 ID(若为空则尝试从配置推断)。 type: string warehouse_name: + description: 需要入库的仓库名称;若为空则仅创建不入库。 type: string required: - material_name @@ -661,15 +668,20 @@ bioyond_cell: goal: properties: board_type: + description: 板类型,如 "5ml分液瓶板"、"配液瓶(小)板" type: string bottle_type: + description: 瓶类型,如 "5ml分液瓶"、"配液瓶(小)" type: string location_code: + description: 库位编号,例如 "A01" type: string name: + description: 物料名称 type: string warehouse_name: default: 手动堆栈 + description: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左"、"自动堆栈-右" 等 type: string required: - name @@ -1956,19 +1968,19 @@ bioyond_cell: properties: source_wh_id: default: 3a19debc-84b4-0359-e2d4-b3beea49348b - description: 来源仓库ID + description: 来源仓库 Id (默认为3号仓库) type: string source_x: default: 1 - description: 来源位置X坐标 + description: 来源位置 X 坐标 type: integer source_y: default: 1 - description: 来源位置Y坐标 + description: 来源位置 Y 坐标 type: integer source_z: default: 1 - description: 来源位置Z坐标 + description: 来源位置 Z 坐标 type: integer required: [] type: object @@ -2061,9 +2073,11 @@ bioyond_cell: goal: properties: order_code: + description: 任务编号 type: string timeout: default: 36000 + description: 超时时间(秒) type: integer required: - order_code @@ -2092,12 +2106,15 @@ bioyond_cell: goal: properties: order_code: + description: 任务编号 type: string poll_interval: default: 0.5 + description: 轮询间隔(秒),默认 0.5 秒 type: number timeout: default: 36000 + description: 超时时间(秒) type: integer required: - order_code @@ -2154,10 +2171,15 @@ bioyond_cell: config: properties: bioyond_config: + description: '从 JSON 文件加载的 bioyond 配置字典 + + 包含 api_host, api_key, HTTP_host, HTTP_port 等配置' type: object deck: + description: Deck 配置(可选,会从 JSON 中自动处理) type: string protocol_type: + description: 协议类型(可选) type: string required: [] type: object diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml index 547b54ff..21f36e16 100644 --- a/unilabos/registry/devices/bioyond_dispensing_station.yaml +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -47,8 +47,10 @@ bioyond_dispensing_station: goal: properties: report_request: + description: WorkstationReportRequest 对象,包含任务完成信息 type: string used_materials: + description: 物料使用记录列表 type: string required: - report_request @@ -102,6 +104,7 @@ bioyond_dispensing_station: goal: properties: material_name: + description: 物料名称 type: string required: - material_name @@ -611,10 +614,10 @@ bioyond_dispensing_station: goal: properties: target_device_id: - description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备) + description: 目标反应站设备ID(所有转移组使用同一个设备) type: string transfer_groups: - description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组 + description: '转移任务组列表,每组包含:' type: array required: - target_device_id @@ -694,10 +697,13 @@ bioyond_dispensing_station: config: properties: config: + description: 配置字典,应包含material_type_mappings等配置 type: object deck: + description: Deck对象 type: string protocol_type: + description: 协议类型(由ROS系统传递,此处忽略) type: string required: [] type: object diff --git a/unilabos/registry/devices/coin_cell_workstation.yaml b/unilabos/registry/devices/coin_cell_workstation.yaml index df5a3508..b692506c 100644 --- a/unilabos/registry/devices/coin_cell_workstation.yaml +++ b/unilabos/registry/devices/coin_cell_workstation.yaml @@ -150,15 +150,15 @@ coincellassemblyworkstation_device: properties: assembly_pressure: default: 4200 - description: 电池压制力(N) + description: 电池压制力 (N) type: integer assembly_type: default: 7 - description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) + description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) type: integer battery_clean_ignore: default: false - description: 是否忽略电池清洁步骤 + description: 是否忽略电池清洁 type: boolean battery_pressure_mode: default: true @@ -166,29 +166,29 @@ coincellassemblyworkstation_device: type: boolean dual_drop_first_volume: default: 25 - description: 二次滴液第一次排液体积(μL) + description: 二次滴液第一次排液体积 (μL) type: integer dual_drop_mode: default: false - description: 电解液添加模式(false=单次滴液, true=二次滴液) + description: 电解液添加模式 (False=单次滴液, True=二次滴液) type: boolean dual_drop_start_timing: default: false - description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) + description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后) type: boolean dual_drop_suction_timing: default: false - description: 二次滴液吸液时机(false=正常吸液, true=先吸液) + description: 二次滴液吸液时机 (False=正常吸液, True=先吸液) type: boolean elec_num: description: 电解液瓶数 type: string elec_use_num: - description: 每瓶电解液组装电池数 + description: 每瓶电解液组装的电池数 type: string elec_vol: default: 50 - description: 电解液吸液量(μL) + description: 电解液吸液量 (μL) type: integer file_path: default: /Users/sml/work @@ -196,7 +196,7 @@ coincellassemblyworkstation_device: type: string fujipian_juzhendianwei: default: 0 - description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 负极片矩阵点位 type: integer fujipian_panshu: default: 0 @@ -204,7 +204,7 @@ coincellassemblyworkstation_device: type: integer gemo_juzhendianwei: default: 0 - description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 隔膜矩阵点位 type: integer gemopanshu: default: 0 @@ -216,7 +216,7 @@ coincellassemblyworkstation_device: type: boolean qiangtou_juzhendianwei: default: 0 - description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 枪头盒矩阵点位 type: integer required: - elec_num @@ -308,7 +308,13 @@ coincellassemblyworkstation_device: properties: material_search_enable: default: false - description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻) + description: '是否启用物料搜寻功能。 + + 设备初始化后会弹出物料搜寻确认弹窗, + + 此参数控制自动点击''是''(启用)或''否''(不启用)。 + + 默认为False(不启用物料搜寻)。' type: boolean required: [] type: object @@ -547,15 +553,15 @@ coincellassemblyworkstation_device: properties: assembly_pressure: default: 4200 - description: 电池压制力(N) + description: 电池压制力 (N) type: integer assembly_type: default: 7 - description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) + description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) type: integer battery_clean_ignore: default: false - description: 是否忽略电池清洁步骤 + description: 是否忽略电池清洁 type: boolean battery_pressure_mode: default: true @@ -563,29 +569,29 @@ coincellassemblyworkstation_device: type: boolean dual_drop_first_volume: default: 25 - description: 二次滴液第一次排液体积(μL) + description: 二次滴液第一次排液体积 (μL) type: integer dual_drop_mode: default: false - description: 电解液添加模式(false=单次滴液, true=二次滴液) + description: 电解液添加模式 (False=单次滴液, True=二次滴液) type: boolean dual_drop_start_timing: default: false - description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) + description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后) type: boolean dual_drop_suction_timing: default: false - description: 二次滴液吸液时机(false=正常吸液, true=先吸液) + description: 二次滴液吸液时机 (False=正常吸液, True=先吸液) type: boolean elec_num: - description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数 + description: 电解液瓶数 type: string elec_use_num: - description: 每瓶电解液组装电池数 + description: 每瓶电解液组装的电池数 type: string elec_vol: default: 50 - description: 电解液吸液量(μL) + description: 电解液吸液量 (μL) type: integer file_path: default: /Users/sml/work @@ -593,7 +599,7 @@ coincellassemblyworkstation_device: type: string fujipian_juzhendianwei: default: 0 - description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 负极片矩阵点位 type: integer fujipian_panshu: default: 0 @@ -601,7 +607,7 @@ coincellassemblyworkstation_device: type: integer gemo_juzhendianwei: default: 0 - description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 隔膜矩阵点位 type: integer gemopanshu: default: 0 @@ -613,7 +619,7 @@ coincellassemblyworkstation_device: type: boolean qiangtou_juzhendianwei: default: 0 - description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 枪头盒矩阵点位 type: integer required: - elec_num diff --git a/unilabos/registry/devices/hotel.yaml b/unilabos/registry/devices/hotel.yaml index fdcc89dd..15d96286 100644 --- a/unilabos/registry/devices/hotel.yaml +++ b/unilabos/registry/devices/hotel.yaml @@ -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 diff --git a/unilabos/registry/devices/laiyu_liquid_test.yaml b/unilabos/registry/devices/laiyu_liquid_test.yaml index 6d87f429..e3494cac 100644 --- a/unilabos/registry/devices/laiyu_liquid_test.yaml +++ b/unilabos/registry/devices/laiyu_liquid_test.yaml @@ -18,6 +18,7 @@ xyz_stepper_controller: goal: properties: degrees: + description: 角度值 type: number required: - degrees @@ -44,6 +45,7 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object required: - axis @@ -71,6 +73,7 @@ xyz_stepper_controller: properties: enable: default: true + description: True为使能,False为失能 type: boolean required: [] type: object @@ -99,9 +102,11 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object enable: default: true + description: True为使能,False为失能 type: boolean required: - axis @@ -152,6 +157,7 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object required: - axis @@ -183,16 +189,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度(rpm/s) type: integer axis: + description: 电机轴 type: object position: + description: 目标位置(步数) type: integer precision: default: 100 + description: 到位精度 type: integer speed: default: 5000 + description: 运行速度(rpm) type: integer required: - axis @@ -225,16 +236,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer axis: + description: 电机轴 type: object degrees: + description: 目标角度(度) type: number precision: default: 100 + description: 精度 type: integer speed: default: 5000 + description: 移动速度 type: integer required: - axis @@ -267,16 +283,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer axis: + description: 电机轴 type: object precision: default: 100 + description: 精度 type: integer revolutions: + description: 目标圈数 type: number speed: default: 5000 + description: 移动速度 type: integer required: - axis @@ -309,15 +330,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 运行速度 type: integer x: + description: X轴目标位置 type: integer y: + description: Y轴目标位置 type: integer z: + description: Z轴目标位置 type: integer required: [] type: object @@ -350,15 +376,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 移动速度 type: integer x_deg: + description: X轴目标角度(度) type: number y_deg: + description: Y轴目标角度(度) type: number z_deg: + description: Z轴目标角度(度) type: number required: [] type: object @@ -391,15 +422,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 移动速度 type: integer x_rev: + description: X轴目标圈数 type: number y_rev: + description: Y轴目标圈数 type: number z_rev: + description: Z轴目标圈数 type: number required: [] type: object @@ -427,6 +463,7 @@ xyz_stepper_controller: goal: properties: revolutions: + description: 圈数 type: number required: - revolutions @@ -456,10 +493,13 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度(rpm/s) type: integer axis: + description: 电机轴 type: object speed: + description: 运行速度(rpm),正值正转,负值反转 type: integer required: - axis @@ -487,6 +527,7 @@ xyz_stepper_controller: goal: properties: steps: + description: 步数 type: integer required: - steps @@ -513,6 +554,7 @@ xyz_stepper_controller: goal: properties: steps: + description: 步数 type: integer required: - steps @@ -564,9 +606,11 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object timeout: default: 30.0 + description: 超时时间(秒) type: number required: - axis @@ -591,11 +635,14 @@ xyz_stepper_controller: properties: baudrate: default: 115200 + description: 波特率 type: integer port: + description: 串口端口名 type: string timeout: default: 1.0 + description: 通信超时时间 type: number required: - port diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index d7a82e35..06afc044 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -510,9 +510,11 @@ liquid_handler: goal: properties: msg: + description: information to be printed type: string seconds: default: 0 + description: seconds to wait type: string required: [] type: object @@ -2963,15 +2965,22 @@ liquid_handler: additionalProperties: false properties: channel: + description: int maximum: 2147483647 minimum: -2147483648 type: integer dis_to_top: + description: 'float + + Height in mm to move to relative to the well top.' maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number well: additionalProperties: false + description: 'Well + + The target well.' properties: category: type: string @@ -4829,11 +4838,13 @@ liquid_handler: config: properties: backend: + description: Backend to use. type: object channel_num: default: 8 type: integer deck: + description: Deck to use. type: object simulator: default: false @@ -4883,14 +4894,17 @@ liquid_handler.biomek: bind_parent_id: type: string liquid_input_slot: + description: 液体输入槽列表 items: type: integer type: array liquid_type: + description: 液体类型列表 items: type: string type: array liquid_volume: + description: 液体体积列表 items: type: integer type: array @@ -4901,6 +4915,7 @@ liquid_handler.biomek: type: object type: array slot_on_deck: + description: 甲板上的槽位 type: integer required: - resource_tracker @@ -5036,20 +5051,27 @@ liquid_handler.biomek: additionalProperties: false properties: none_keys: + description: 需要设置为None的键列表 items: type: string type: array protocol_author: + description: 协议作者 type: string protocol_date: + description: 协议日期 type: string protocol_description: + description: 协议描述 type: string protocol_name: + description: 协议名称 type: string protocol_type: + description: 协议类型 type: string protocol_version: + description: 协议版本 type: string title: LiquidHandlerProtocolCreation_Goal type: object diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 4f3b972a..63411b53 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -87,7 +87,7 @@ neware_battery_test_system: properties: filepath: default: bts_status.json - description: 输出JSON文件路径 + description: 输出文件路径 type: string required: [] type: object @@ -146,7 +146,7 @@ neware_battery_test_system: goal: properties: plate_num: - description: 盘号 (1 或 2),如果为null则返回所有盘的状态 + description: 盘号 (1 或 2),如果为None则返回所有盘的状态 type: integer required: [] type: object @@ -237,11 +237,11 @@ neware_battery_test_system: goal: properties: csv_path: - description: 输入CSV文件的绝对路径 + description: 输入CSV文件路径 type: string output_dir: default: . - description: 输出目录(用于存储XML和备份文件),默认当前目录 + description: 输出目录,用于存储XML文件和备份,默认当前目录 type: string required: - csv_path @@ -302,14 +302,14 @@ neware_battery_test_system: goal: properties: backup_dir: - description: 备份目录路径(默认使用最近一次submit_from_csv的backup_dir) + description: 备份目录路径,默认使用最近一次 submit_from_csv 的 backup_dir type: string file_pattern: default: '*' - description: 文件通配符模式,例如 *.csv 或 Battery_*.nda + description: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件) type: string oss_prefix: - description: OSS对象路径前缀(默认使用self.oss_prefix) + description: OSS 对象前缀,默认使用类初始化时的配置 type: string required: [] type: object @@ -336,19 +336,25 @@ neware_battery_test_system: config: properties: devtype: + description: 设备类型标识 type: string ip: + description: TCP服务器IP地址 type: string machine_id: default: 1 + description: 机器ID type: integer oss_prefix: default: neware_backup + description: OSS对象路径前缀,默认"neware_backup" type: string oss_upload_enabled: default: false + description: 是否启用OSS上传功能,默认False type: boolean port: + description: TCP端口 type: integer size_x: default: 50 @@ -360,6 +366,7 @@ neware_battery_test_system: default: 20 type: number timeout: + description: 通信超时时间(秒) type: integer required: [] type: object diff --git a/unilabos/registry/devices/organic_miscellaneous.yaml b/unilabos/registry/devices/organic_miscellaneous.yaml index c1290bea..dc81b671 100644 --- a/unilabos/registry/devices/organic_miscellaneous.yaml +++ b/unilabos/registry/devices/organic_miscellaneous.yaml @@ -207,8 +207,12 @@ separator.homemade: goal: properties: condition: + description: The condition to be monitored, either 'delta' or 'time'. type: string value: + description: 'The threshold value for the condition. + + `delta > 0.05`, `time > 60`' type: string required: - condition @@ -305,12 +309,17 @@ separator.homemade: event: type: string settling_time: + description: The duration for which to settle after stirring, in + seconds. Defaults to 10. type: string stir_speed: + description: The speed of stirring, in RPM. Defaults to 300. maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number stir_time: + description: The duration for which to stir, in seconds. Defaults + to 10. maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number diff --git a/unilabos/registry/devices/pump_and_valve.yaml b/unilabos/registry/devices/pump_and_valve.yaml index 95a082d5..25d647f7 100644 --- a/unilabos/registry/devices/pump_and_valve.yaml +++ b/unilabos/registry/devices/pump_and_valve.yaml @@ -456,6 +456,7 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -481,6 +482,7 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -687,8 +689,10 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: max_velocity: + description: 'maximum velocity of the plunger, unit: ml/s' type: number position: + description: 'absolute position of the plunger, unit: ml' type: number required: - position @@ -1003,6 +1007,7 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -1028,6 +1033,7 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -1234,8 +1240,10 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: max_velocity: + description: 'maximum velocity of the plunger, unit: ml/s' type: number position: + description: 'absolute position of the plunger, unit: ml' type: number required: - position diff --git a/unilabos/registry/devices/reaction_station_bioyond.yaml b/unilabos/registry/devices/reaction_station_bioyond.yaml index 1372140d..7ab22df6 100644 --- a/unilabos/registry/devices/reaction_station_bioyond.yaml +++ b/unilabos/registry/devices/reaction_station_bioyond.yaml @@ -32,7 +32,7 @@ reaction_station.bioyond: type: integer end_point: default: 0 - description: 终点计时点 (Start=开始前, End=结束后) + description: 终点计时点 (Start=0, End=1) type: integer end_step_key: default: '' @@ -40,11 +40,11 @@ reaction_station.bioyond: type: string start_point: default: 0 - description: 起点计时点 (Start=开始前, End=结束后) + description: 起点计时点 (Start=0, End=1) type: integer start_step_key: default: '' - description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择) + description: 起点步骤Key (可选, 默认为空则自动选择) type: string required: - duration @@ -91,6 +91,7 @@ reaction_station.bioyond: goal: properties: json_str: + description: 订单参数的JSON字符串 type: string required: - json_str @@ -117,6 +118,7 @@ reaction_station.bioyond: goal: properties: workflow_ids: + description: 要删除的工作流ID数组 items: type: string type: array @@ -145,6 +147,7 @@ reaction_station.bioyond: goal: properties: json_str: + description: 'JSON格式的字符串,包含:' type: string required: - json_str @@ -197,6 +200,7 @@ reaction_station.bioyond: goal: properties: web_workflow_json: + description: JSON 格式的网页工作流列表 type: string required: - web_workflow_json @@ -228,8 +232,10 @@ reaction_station.bioyond: goal: properties: reactor_id: + description: 反应器编号 (1-5) type: integer temperature: + description: 目标温度 (°C) type: number required: - reactor_id @@ -257,6 +263,7 @@ reaction_station.bioyond: goal: properties: preintake_id: + description: 通量ID type: string required: - preintake_id @@ -338,6 +345,7 @@ reaction_station.bioyond: goal: properties: value: + description: 工作流 ID 列表 items: type: string type: array @@ -365,6 +373,7 @@ reaction_station.bioyond: goal: properties: workflow_id: + description: 工作流ID type: string required: - workflow_id @@ -424,11 +433,11 @@ reaction_station.bioyond: goal: properties: assign_material_name: - description: 物料名称(不能为空) + description: 物料名称(液体种类) type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度(C) type: number time: default: '90' @@ -436,14 +445,14 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer volume: - description: 分液公式(mL) + description: 分液量(μL) type: string required: - assign_material_name @@ -525,11 +534,11 @@ reaction_station.bioyond: properties: assign_material_name: default: BAPP - description: 物料名称 + description: 物料名称(试剂瓶位) type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度设定(C) type: number time: default: '0' @@ -537,15 +546,15 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(int类型, 1=否, 2=是) type: integer volume: default: '350' - description: 分液公式(mL) + description: 分液质量(g) type: string required: [] type: object @@ -593,26 +602,28 @@ reaction_station.bioyond: description: 物料名称 type: string solvents: - description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume' + description: '溶剂信息的字典或JSON字符串(可选),格式如下: + + {' type: string temperature: default: 25.0 - description: 温度设定(°C),默认25.00 + description: 温度设定(C) type: number time: default: '360' - description: 观察时间(分钟),默认360 + description: 观察时间(分钟) type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是),默认NO + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是),默认YES + description: 是否观察(NO=1, YES=2) type: integer volume: - description: 分液量(mL)。可直接提供,或通过solvents参数自动计算 + description: 分液量(μL),直接指定体积(可选,如果提供solvents则自动计算) type: string required: - assign_material_name @@ -671,33 +682,32 @@ reaction_station.bioyond: description: 物料名称 type: string extracted_actuals: - description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定) + description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume type: string feeding_order_data: - description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例: - {"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}' + description: feeding_order JSON字符串或对象,用于获取m二酐值 type: string temperature: default: 25.0 - description: 温度设定(°C),默认25.00 + description: 温度(C) type: number time: default: '90' - description: 观察时间(分钟),默认90 + description: 观察时间(分钟) type: string titration_type: default: '2' - description: 是否滴定(NO=否, YES=是),默认YES + description: 是否滴定(NO=1, YES=2),默认2 type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是),默认YES + description: 是否观察(NO=1, YES=2) type: integer volume_formula: - description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成 + description: 分液公式(μL),如果提供则直接使用,否则自动计算 type: string x_value: - description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算 + description: 手工输入的x值,格式如 "1-2-3" type: string required: - assign_material_name @@ -738,7 +748,7 @@ reaction_station.bioyond: type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度(C) type: number time: default: '0' @@ -746,14 +756,14 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer volume_formula: - description: 分液公式(mL) + description: 分液公式(μL) type: string required: - volume_formula @@ -786,7 +796,7 @@ reaction_station.bioyond: description: 任务名称 type: string workflow_name: - description: 工作流名称 + description: 合并后的工作流名称 type: string required: - workflow_name @@ -819,15 +829,15 @@ reaction_station.bioyond: goal: properties: assign_material_name: - description: 物料名称 + description: 物料名称(不能为空) type: string cutoff: default: '900000' - description: 粘度上限 + description: 粘度上限(需为有效数字字符串,默认 "900000") type: string temperature: default: -10.0 - description: 温度设定(°C) + description: 温度设定(C,范围:-50.00 至 100.00) type: number required: - assign_material_name @@ -909,11 +919,11 @@ reaction_station.bioyond: description: 物料名称(用于获取试剂瓶位ID) type: string material_id: - description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟) + description: 粉末类型ID, Salt=1, Flour=2, BTDA=3 type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度设定(C) type: number time: default: '0' @@ -921,7 +931,7 @@ reaction_station.bioyond: type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer required: - material_id @@ -945,10 +955,13 @@ reaction_station.bioyond: config: properties: config: + description: 配置字典,应包含workflow_mappings等配置 type: object deck: + description: Deck对象 type: string protocol_type: + description: 协议类型(由ROS系统传递,此处忽略) type: string required: [] type: object diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index a2d60573..90b3da38 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -257,6 +257,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes quaternion, speed, + position type: string title: SendCmd_Goal type: object @@ -300,6 +302,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes speed type: string title: SendCmd_Goal type: object @@ -345,7 +348,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: diff --git a/unilabos/registry/devices/robot_linear_motion.yaml b/unilabos/registry/devices/robot_linear_motion.yaml index 6a36dae0..fc580f48 100644 --- a/unilabos/registry/devices/robot_linear_motion.yaml +++ b/unilabos/registry/devices/robot_linear_motion.yaml @@ -768,6 +768,8 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes quaternion, speed, + position type: string title: SendCmd_Goal type: object @@ -811,6 +813,7 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes speed type: string title: SendCmd_Goal type: object diff --git a/unilabos/registry/devices/virtual_device.yaml b/unilabos/registry/devices/virtual_device.yaml index 0fce3824..a34d6f55 100644 --- a/unilabos/registry/devices/virtual_device.yaml +++ b/unilabos/registry/devices/virtual_device.yaml @@ -2179,6 +2179,7 @@ virtual_multiway_valve: goal: properties: port_number: + description: 端口号 (1-8) type: integer required: - port_number @@ -2225,6 +2226,7 @@ virtual_multiway_valve: goal: properties: port_number: + description: 目标端口号 (1-8) type: integer required: - port_number @@ -2261,6 +2263,7 @@ virtual_multiway_valve: additionalProperties: false properties: command: + description: 目标位置 (0-8) 或位置字符串 type: string title: SendCmd_Goal type: object @@ -2304,6 +2307,7 @@ virtual_multiway_valve: additionalProperties: false properties: command: + description: 目标位置 (0-8) 或位置字符串 type: string title: SendCmd_Goal type: object @@ -3960,6 +3964,14 @@ virtual_separator: io_type: source label: bottom_phase_out side: SOUTH + - data_key: top_outlet + data_source: executor + data_type: fluid + description: 上相(轻相)液体输出口 + handler_key: topphaseout + io_type: source + label: top_phase_out + side: NORTH - data_key: mechanical_port data_source: handle data_type: mechanical @@ -4207,6 +4219,7 @@ virtual_solenoid_valve: additionalProperties: false properties: string: + description: '"ON"/"OFF" 或 "OPEN"/"CLOSED"' type: string title: StrSingleInput_Goal type: object @@ -4250,6 +4263,7 @@ virtual_solenoid_valve: additionalProperties: false properties: command: + description: '"OPEN"/"CLOSED" 或其他控制命令' type: string title: SendCmd_Goal type: object @@ -4410,16 +4424,20 @@ virtual_solid_dispenser: event: type: string mass: + description: 质量字符串 (如 "2.9 g") type: string mol: + description: 摩尔数字符串 (如 "0.12 mol") type: string purpose: + description: 添加目的 type: string rate_spec: type: string ratio: type: string reagent: + description: 试剂名称 type: string stir: type: boolean @@ -4431,6 +4449,7 @@ virtual_solid_dispenser: type: string vessel: additionalProperties: false + description: 目标容器 properties: category: type: string @@ -5560,8 +5579,10 @@ virtual_transfer_pump: goal: properties: velocity: + description: 拉取速度 (ml/s) type: number volume: + description: 要拉取的体积 (ml) type: number required: - volume @@ -5588,8 +5609,10 @@ virtual_transfer_pump: goal: properties: velocity: + description: 推出速度 (ml/s) type: number volume: + description: 要推出的体积 (ml) type: number required: - volume @@ -5685,10 +5708,12 @@ virtual_transfer_pump: additionalProperties: false properties: max_velocity: + description: 移动速度 (ml/s) maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number position: + description: 目标位置 (ml) maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number @@ -5837,8 +5862,10 @@ virtual_transfer_pump: config: properties: config: + description: 配置字典,包含max_volume, port等参数 type: object device_id: + description: 设备ID type: string required: [] type: object diff --git a/unilabos/registry/devices/xrd_d7mate.yaml b/unilabos/registry/devices/xrd_d7mate.yaml index 2b49ae55..38e31718 100644 --- a/unilabos/registry/devices/xrd_d7mate.yaml +++ b/unilabos/registry/devices/xrd_d7mate.yaml @@ -409,11 +409,11 @@ xrd_d7mate: properties: end_theta: default: 80.0 - description: 结束角度(≥5.5°,且必须大于start_theta) + description: 结束角度(≥5.5°,且必须大于 start_theta) type: number exp_time: default: 0.1 - description: 曝光时间(0.1-5.0秒) + description: 曝光时间(0.1-5.0 秒) type: number increment: default: 0.05 @@ -421,7 +421,7 @@ xrd_d7mate: type: number sample_id: default: '' - description: 样品标识符 + description: 样品名称 type: string start_theta: default: 10.0 @@ -433,7 +433,7 @@ xrd_d7mate: type: string wait_minutes: default: 3.0 - description: 允许上样后等待分钟数 + description: 在允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟) type: number required: [] title: StartWorkflow_Goal @@ -492,12 +492,15 @@ xrd_d7mate: properties: host: default: 127.0.0.1 + description: 设备IP地址 type: string port: default: 6001 + description: 通信端口,默认6001 type: string timeout: default: 10.0 + description: 超时时间,单位秒 type: string required: [] type: object diff --git a/unilabos/registry/devices/zhida_gcms.yaml b/unilabos/registry/devices/zhida_gcms.yaml index 37adbd79..b10b29ad 100644 --- a/unilabos/registry/devices/zhida_gcms.yaml +++ b/unilabos/registry/devices/zhida_gcms.yaml @@ -217,6 +217,7 @@ zhida_gcms: additionalProperties: false properties: string: + description: Base64编码的CSV数据(ROS2参数名) type: string title: StrSingleInput_Goal type: object @@ -257,6 +258,7 @@ zhida_gcms: additionalProperties: false properties: string: + description: CSV文件路径(ROS2参数名) type: string title: StrSingleInput_Goal type: object @@ -289,12 +291,15 @@ zhida_gcms: properties: host: default: 192.168.3.184 + description: 设备IP地址,本地部署时可使用'127.0.0.1' type: string port: default: 5792 + description: 通信端口,默认5792 type: string timeout: default: 10.0 + description: 超时时间,单位秒 type: string required: [] type: object diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 15b1b537..75677b4f 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -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, @@ -270,6 +271,7 @@ class Registry: registry_cache.pkl 一个文件中,删除即可完全重置。 """ import time as _time + from unilabos.registry.ast_registry_scanner import _CACHE_VERSION as AST_SCAN_CACHE_VERSION from unilabos.registry.ast_registry_scanner import scan_directory scan_t0 = _time.perf_counter() @@ -285,6 +287,10 @@ class Registry: # ---- 统一缓存:一个 pkl 包含所有数据 ---- unified_cache = self._load_config_cache() ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}}) + if ast_cache.get("version") != AST_SCAN_CACHE_VERSION: + ast_cache = {"version": AST_SCAN_CACHE_VERSION, "files": {}} + unified_cache["_ast_scan"] = ast_cache + unified_cache.pop("_build_results", None) # 默认:扫描 unilabos 包所在的父目录 pkg_root = Path(__file__).resolve().parent.parent # .../unilabos @@ -560,13 +566,47 @@ class Registry: return prop_schema + @staticmethod + def _apply_docstring_param_metadata( + schema: Dict[str, Any], + doc_info: Dict[str, Any], + field_to_param: Optional[Dict[str, str]] = None, + apply_defaults: bool = False, + ) -> None: + """Apply parsed docstring display names and descriptions to schema properties.""" + if not schema or not doc_info: + return + + props = schema.get("properties", {}) + if not isinstance(props, dict): + return + + param_descs = doc_info.get("params", {}) or {} + param_display_names = doc_info.get("param_display_names", {}) or {} + for field_name, prop_schema in props.items(): + if not isinstance(prop_schema, dict): + continue + param_name = field_to_param.get(field_name, field_name) if field_to_param else field_name + if not isinstance(param_name, str): + continue + param_name = param_name.removesuffix("[]") + if param_name in param_display_names: + prop_schema["title"] = param_display_names[param_name] + elif apply_defaults and not prop_schema.get("title"): + prop_schema["title"] = field_name + + if param_name in param_descs: + prop_schema["description"] = param_descs[param_name] + elif apply_defaults and "description" not in prop_schema: + prop_schema["description"] = "" + def _generate_unilab_json_command_schema( self, method_args: list, docstring: Optional[str] = None, import_map: Optional[Dict[str, str]] = None, + apply_doc_defaults: bool = False, ) -> Dict[str, Any]: """根据方法参数和 docstring 生成 UniLabJsonCommand schema""" doc_info = parse_docstring(docstring) - param_descs = doc_info.get("params", {}) schema = { "type": "object", @@ -597,12 +637,10 @@ class Registry: param_name, param_type, param_default, import_map=import_map ) - if param_name in param_descs: - schema["properties"][param_name]["description"] = param_descs[param_name] - if param_required: schema["required"].append(param_name) + self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=apply_doc_defaults) return schema def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]: @@ -798,6 +836,7 @@ class Registry: type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand" params = method_info.get("params", []) method_doc = method_info.get("docstring") + method_doc_info = parse_docstring(method_doc) goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap) if action_args is not None: @@ -827,10 +866,15 @@ class Registry: # action handles: 从 @action(handles=[...]) 提取并转换为标准格式 raw_handles = (action_args or {}).get("handles") - handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) + 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 @@ -845,13 +889,20 @@ class Registry: "goal": goal, "feedback": (action_args or {}).get("feedback") or {}, "result": (action_args or {}).get("result") or {}, - "schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema), + "schema": wrap_action_schema( + goal_schema, + action_name, + description=(action_args or {}).get("description") or method_doc_info.get("description", ""), + result_schema=result_schema, + ), "goal_default": goal_default, "handles": handles, "placeholder_keys": pk, } 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 @@ -882,7 +933,11 @@ class Registry: action_name = f"auto-{action_name}" raw_handles = action_args.get("handles") - handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) + handles = ( + normalize_ast_action_handles(raw_handles) + if isinstance(raw_handles, list) + else (raw_handles or {}) + ) method_params = method_info.get("params", []) @@ -975,20 +1030,34 @@ 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 + goal_schema_for_docs = action_entry.get("schema", {}).get("properties", {}).get("goal", {}) + self._apply_docstring_param_metadata( + goal_schema_for_docs, + parse_docstring(method_info.get("docstring")), + goal, + apply_defaults=True, + ) action_value_mappings[action_name] = action_entry action_value_mappings = dict(sorted(action_value_mappings.items())) # --- init_param_schema = { config: , data: } --- init_params = ast_meta.get("init_params", []) - config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap) + config_schema = self._generate_schema_from_ast_params( + init_params, "__init__", ast_meta.get("init_docstring"), import_map=imap + ) data_schema = self._generate_status_schema_from_ast( ast_meta.get("status_properties", {}), imap ) @@ -1036,7 +1105,6 @@ class Registry: ) -> Dict[str, Any]: """Generate JSON Schema from AST-extracted parameter list.""" doc_info = parse_docstring(docstring) - param_descs = doc_info.get("params", {}) schema: Dict[str, Any] = { "type": "object", @@ -1066,12 +1134,10 @@ class Registry: pname, ptype, pdefault, import_map ) - if pname in param_descs: - schema["properties"][pname]["description"] = param_descs[pname] - if prequired: schema["required"].append(pname) + self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=True) return schema def _generate_status_schema_from_ast( @@ -1801,7 +1867,7 @@ class Registry: else: action_key = f"auto-{k}" goal_schema = self._generate_unilab_json_command_schema( - v["args"], import_map=enhanced_import_map + v["args"], docstring=v.get("docstring"), import_map=enhanced_import_map ) ret_type = v.get("return_type", "") result_schema = None @@ -1810,7 +1876,13 @@ class Registry: "result", ret_type, None, import_map=enhanced_import_map ) old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {}) - new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema) + doc_info = parse_docstring(v.get("docstring")) + new_schema = wrap_action_schema( + goal_schema, + action_key, + description=doc_info.get("description", ""), + result_schema=result_schema, + ) old_schema = old_cfg.get("schema", {}) if old_schema: preserve_field_descriptions(new_schema, old_schema) @@ -1876,6 +1948,12 @@ class Registry: merged_pk = dict(old_cfg.get("placeholder_keys", {})) merged_pk.update(detect_placeholder_keys(v["args"])) + goal_schema_for_docs = ( + entry_schema.get("properties", {}).get("goal", {}) + if isinstance(entry_schema, dict) + else {} + ) + self._apply_docstring_param_metadata(goal_schema_for_docs, doc_info, entry_goal) entry = { "type": entry_type, @@ -1896,7 +1974,8 @@ class Registry: device_config["init_param_schema"] = {} init_schema = self._generate_unilab_json_command_schema( - enhanced_info["init_params"], "__init__", + enhanced_info["init_params"], + docstring=enhanced_info.get("init_docstring"), import_map=enhanced_import_map, ) device_config["init_param_schema"]["config"] = init_schema @@ -1943,7 +2022,9 @@ class Registry: action_str_type_mapping[action_type_str] = target_type if target_type is not None: try: - action_config["goal_default"] = ROS2MessageInstance(target_type.Goal()).get_python_dict() + action_config["goal_default"] = ROS2MessageInstance( + target_type.Goal() + ).get_python_dict() except Exception: action_config["goal_default"] = {} prev_schema = action_config.get("schema", {}) @@ -2135,6 +2216,7 @@ class Registry: "unilabos_device_id": { "type": "string", "default": "", + "title": "设备ID", "description": "UniLabOS设备ID,用于指定执行动作的具体设备实例", }, **schema["properties"]["goal"]["properties"], @@ -2206,7 +2288,14 @@ class Registry: lab_registry = Registry() -def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False): +def build_registry( + registry_paths=None, + devices_dirs=None, + upload_registry=False, + check_mode=False, + complete_registry=False, + external_only=False, +): """ 构建或获取Registry单例实例 """ @@ -2220,7 +2309,12 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False if path not in current_paths: lab_registry.registry_paths.append(path) - lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only) + lab_registry.setup( + devices_dirs=devices_dirs, + upload_registry=upload_registry, + complete_registry=complete_registry, + external_only=external_only, + ) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) lab_registry.resolve_all_types() diff --git a/unilabos/registry/resources/common/resource_container.yaml b/unilabos/registry/resources/common/resource_container.yaml index 3f0aa9d2..751f1aa5 100644 --- a/unilabos/registry/resources/common/resource_container.yaml +++ b/unilabos/registry/resources/common/resource_container.yaml @@ -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 diff --git a/unilabos/registry/resources/laiyu/container.yaml b/unilabos/registry/resources/laiyu/container.yaml index 586e3cfe..400bc931 100644 --- a/unilabos/registry/resources/laiyu/container.yaml +++ b/unilabos/registry/resources/laiyu/container.yaml @@ -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 diff --git a/unilabos/registry/resources/laiyu/deck.yaml b/unilabos/registry/resources/laiyu/deck.yaml index 85da0ca7..89973dde 100644 --- a/unilabos/registry/resources/laiyu/deck.yaml +++ b/unilabos/registry/resources/laiyu/deck.yaml @@ -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 diff --git a/unilabos/registry/resources/opentrons/deck.yaml b/unilabos/registry/resources/opentrons/deck.yaml index 10e91cef..0e35e7b1 100644 --- a/unilabos/registry/resources/opentrons/deck.yaml +++ b/unilabos/registry/resources/opentrons/deck.yaml @@ -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 diff --git a/unilabos/registry/resources/opentrons/plates.yaml b/unilabos/registry/resources/opentrons/plates.yaml index 20a71995..883bf147 100644 --- a/unilabos/registry/resources/opentrons/plates.yaml +++ b/unilabos/registry/resources/opentrons/plates.yaml @@ -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: diff --git a/unilabos/registry/resources/opentrons/tip_racks.yaml b/unilabos/registry/resources/opentrons/tip_racks.yaml index d1682b2a..ec838018 100644 --- a/unilabos/registry/resources/opentrons/tip_racks.yaml +++ b/unilabos/registry/resources/opentrons/tip_racks.yaml @@ -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: diff --git a/unilabos/registry/utils.py b/unilabos/registry/utils.py index eb342c5c..6b1acb2b 100644 --- a/unilabos/registry/utils.py +++ b/unilabos/registry/utils.py @@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception): # --------------------------------------------------------------------------- _SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$") +_PARAM_HEADER_RE = re.compile( + r"^\s*(?P\w[\w]*)\s*(?:\[(?P[^\]]+)\])?(?:\s*\([^)]*\))?\s*$" +) + + +def _parse_docstring_param_header(param_part: str) -> Tuple[str, Optional[str]]: + """Parse ``name[display_name]`` or Google-style ``name (type)``.""" + match = _PARAM_HEADER_RE.match(param_part.strip()) + if not match: + return param_part.strip().split("(")[0].strip(), None + + display_name = match.group("display_name") + if display_name is not None: + display_name = display_name.strip() or None + return match.group("name").strip(), display_name def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: """ - 解析 Google-style docstring,提取描述和参数说明。 + 解析 docstring,提取描述和参数说明。 + + 支持: + - Google-style ``Args:`` / ``Parameters:`` 小节 + - 直接参数行 ``field: desc`` + - 带显示名参数行 ``field[Display Name]: desc`` Returns: - {"description": "短描述", "params": {"param1": "参数1描述", ...}} + { + "description": "短描述", + "params": {"param1": "参数1描述", ...}, + "param_display_names": {"param1": "显示名", ...}, + } """ - result: Dict[str, Any] = {"description": "", "params": {}} + result: Dict[str, Any] = {"description": "", "params": {}, "param_display_names": {}} if not docstring: return result @@ -53,33 +77,53 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: if not lines: return result - result["description"] = lines[0].strip() - in_args = False + current_section: Optional[str] = None current_param: Optional[str] = None + current_display_name: Optional[str] = None current_desc_parts: list = [] - for line in lines[1:]: + def flush_current_param() -> None: + nonlocal current_param, current_display_name, current_desc_parts + if current_param is None: + return + result["params"][current_param] = "\n".join(current_desc_parts).strip() + if current_display_name: + result["param_display_names"][current_param] = current_display_name + current_param = None + current_display_name = None + current_desc_parts = [] + + first_line = lines[0].strip() + start_index = 0 + if not _SECTION_RE.match(first_line) and ":" not in first_line: + result["description"] = first_line + start_index = 1 + + for line in lines[start_index:]: stripped = line.strip() + if not stripped: + if current_param is not None: + current_desc_parts.append("") + continue + section_match = _SECTION_RE.match(stripped) if section_match: - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() - current_param = None - current_desc_parts = [] - section_name = section_match.group(1).lower() - in_args = section_name in ("args", "arguments", "parameters", "params") + flush_current_param() + current_section = section_match.group(1).lower() + in_args = current_section in ("args", "arguments", "parameters", "params") continue - if not in_args: + parse_as_param = in_args or current_section is None + if not parse_as_param: continue - if ":" in stripped and not stripped.startswith(" "): - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() + if ":" in stripped: + flush_current_param() param_part, _, desc_part = stripped.partition(":") - param_name = param_part.strip().split("(")[0].strip() + param_name, display_name = _parse_docstring_param_header(param_part) current_param = param_name + current_display_name = display_name current_desc_parts = [desc_part.strip()] elif current_param is not None: aline = line @@ -89,8 +133,7 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: aline = aline[1:] current_desc_parts.append(aline.strip()) - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() + flush_current_param() return result diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 76af4db0..b7cdb689 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -997,7 +997,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: # 如果有液体,使用液体的名称 @@ -1015,7 +1015,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, @@ -1075,7 +1075,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 实际要求的字段(必需) } diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 0362a227..670a8417 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -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,11 @@ class BaseROS2DeviceNode(Node, Generic[T]): Coordinate(location["x"], location["y"], location["z"]), **other_calling_param, ) - # 调整了液体以及Deck之后要重新Assign # noinspection PyUnresolvedReferences rts_with_parent = ResourceTreeSet.from_plr_resources([plr_instance]) 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,6 +639,10 @@ 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) _raw_resp = tree_response.response if tree_response else "" if _raw_resp: @@ -550,8 +651,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): uuid_maps = {} self._lab_logger.warning("Resource tree add 返回空响应,跳过 UUID 映射") 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, @@ -688,7 +791,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) @@ -1137,7 +1244,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): @@ -1187,7 +1295,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): """向注册表中注册设备信息""" @@ -1572,37 +1680,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 timer(10s),不阻塞完成检测 + _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} 已取消") @@ -1611,8 +1757,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"]: continue @@ -1668,7 +1818,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, @@ -1804,7 +1954,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( { @@ -1830,6 +1980,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) @@ -1851,10 +2003,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 @@ -1947,16 +2104,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] diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index c93ab052..e680b160 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -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 @@ -784,22 +786,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: """ @@ -1355,7 +1352,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}") if success: @@ -1371,6 +1368,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): """ @@ -1429,9 +1427,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) diff --git a/unilabos/test/experiments/virtual_bench.json b/unilabos/test/experiments/virtual_bench.json new file mode 100644 index 00000000..0cffe842 --- /dev/null +++ b/unilabos/test/experiments/virtual_bench.json @@ -0,0 +1,469 @@ +{ + "nodes": [ + { + "id": "workbench_1", + "name": "虚拟工作台", + "children": [], + "parent": null, + "type": "device", + "class": "virtual_workbench", + "position": { + "x": 400, + "y": 300, + "z": 0 + }, + "config": { + "arm_operation_time": 3.0, + "heating_time": 10.0, + "num_heating_stations": 3 + }, + "data": { + "status": "Ready", + "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": [] +} diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index 366694be..e3631fa3 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -33,10 +33,76 @@ _USE_UV: Optional[bool] = None def _has_uv() -> bool: global _USE_UV if _USE_UV is None: - _USE_UV = shutil.which("uv") is not None + uv_path = shutil.which("uv") + if not uv_path: + _USE_UV = False + else: + try: + result = subprocess.run([uv_path, "--version"], capture_output=True, text=True, timeout=10) + _USE_UV = result.returncode == 0 + except Exception: + _USE_UV = False return _USE_UV +def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: + if installer == "uv": + cmd = ["uv", "pip", "install"] + if upgrade: + cmd.append("--upgrade") + cmd.append(package) + if is_chinese: + cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + return cmd + + cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"] + if upgrade: + cmd.append("--upgrade") + cmd.append(package) + if is_chinese: + cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + return cmd + + +def _installer_candidates() -> List[str]: + installers: List[str] = [] + if _has_uv(): + installers.append("uv") + installers.append("pip") + return installers + + +def _git_url_from_requirement(requirement: str) -> Optional[str]: + if not requirement.startswith("git+"): + return None + return requirement[4:].split("#", 1)[0] + + +def _repo_dir_name(git_url: str) -> str: + repo_name = git_url.rstrip("/").rsplit("/", 1)[-1] + return repo_name[:-4] if repo_name.endswith(".git") else repo_name + + +def _print_manual_git_install_hint(requirement: str) -> None: + git_url = _git_url_from_requirement(requirement) + if not git_url: + return + + repo_dir = _repo_dir_name(git_url) + install_cmd = "uv pip install -e ." if _has_uv() else f"{sys.executable} -m pip install -e ." + if _is_chinese_locale() and not _has_uv(): + install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" + + print_status("Git 依赖自动安装失败,通常是网络连接被重置或代码托管站点暂时不可达。", "warning") + print_status("可以手动拉取代码后在本地安装:", "warning") + print_status(f" git clone {git_url}", "warning") + print_status(f" cd {repo_dir}", "warning") + print_status(" git pull", "warning") + print_status(f" {install_cmd}", "warning") + print_status(f"如果目录 {repo_dir} 已存在,直接进入该目录执行 git pull 后再安装。", "warning") + print_status("如果 git clone 仍失败,请切换网络/代理,或从浏览器下载源码后进入源码目录执行本地安装命令。", "warning") + + def _install_packages( packages: List[str], upgrade: bool = False, @@ -53,7 +119,7 @@ def _install_packages( return True is_chinese = _is_chinese_locale() - use_uv = _has_uv() + installers = _installer_candidates() failed: List[str] = [] for pkg in packages: @@ -63,35 +129,30 @@ def _install_packages( else: print_status(f"正在{action_word} {pkg}...", "info") - if use_uv: - cmd = ["uv", "pip", "install"] - if upgrade: - cmd.append("--upgrade") - cmd.append(pkg) - if is_chinese: - cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) - else: - cmd = [sys.executable, "-m", "pip", "install"] - if upgrade: - cmd.append("--upgrade") - cmd.append(pkg) - if is_chinese: - cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + pkg_installed = False + last_error = "unknown error" - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - if result.returncode == 0: - installer = "uv" if use_uv else "pip" - print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") - else: - stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" - print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error") - failed.append(pkg) - except subprocess.TimeoutExpired: - print_status(f"× {pkg} {action_word}超时 (300s)", "error") - failed.append(pkg) - except Exception as e: - print_status(f"× {pkg} {action_word}异常: {e}", "error") + for installer in installers: + cmd = _install_command(installer, pkg, upgrade, is_chinese) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") + pkg_installed = True + break + + last_error = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" + print_status(f"× {pkg} {action_word}失败 (via {installer}): {last_error}", "warning") + except subprocess.TimeoutExpired: + last_error = "timeout after 300s" + print_status(f"× {pkg} {action_word}超时 (via {installer}, 300s)", "warning") + except Exception as e: + last_error = str(e) + print_status(f"× {pkg} {action_word}异常 (via {installer}): {e}", "warning") + + if not pkg_installed: + print_status(f"× {pkg} {action_word}失败: {last_error}", "error") + _print_manual_git_install_hint(pkg) failed.append(pkg) if failed: @@ -188,7 +249,13 @@ class EnvironmentChecker: "crcmod": "crcmod-plus", } - self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"} + # 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败 + pylabrobot_url = ( + "git+https://gitee.com/xuwznln/pylabrobot.git" + if _is_chinese_locale() + else "git+https://github.com/Xuwznln/pylabrobot.git" + ) + self.special_packages = {"pylabrobot": pylabrobot_url} self.version_requirements = { "msgcenterpy": "0.1.8", diff --git a/unilabos/utils/import_manager.py b/unilabos/utils/import_manager.py index 7fe2f501..8d0e8bf1 100644 --- a/unilabos/utils/import_manager.py +++ b/unilabos/utils/import_manager.py @@ -206,6 +206,7 @@ class ImportManager: "ast_analysis_success": False, "import_map": {}, "init_params": [], + "init_docstring": None, "status_methods": {}, "action_methods": {}, } @@ -251,6 +252,7 @@ class ImportManager: # 映射到统一字段名(与 registry.py complete_registry 消费端一致) result["init_params"] = body.get("init_params", []) + result["init_docstring"] = body.get("init_docstring") result["status_methods"] = body.get("status_properties", {}) result["action_methods"] = { k: { diff --git a/unilabos/utils/tools.py b/unilabos/utils/tools.py index 3c7b742e..e6719208 100644 --- a/unilabos/utils/tools.py +++ b/unilabos/utils/tools.py @@ -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)) diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index ead5eded..17552117 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.19 + 0.11.1 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln