mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-23 01:59:59 +00:00
Compare commits
38 Commits
dependabot
...
f14e1bc4a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f14e1bc4a0 | ||
|
|
247a0ee4c6 | ||
|
|
a084031af0 | ||
|
|
212f9ec448 | ||
|
|
2fd8f0d3f1 | ||
|
|
a4678b7aa8 | ||
|
|
72495bfc74 | ||
|
|
97ccc38c7f | ||
|
|
1df8fbd173 | ||
|
|
26155b8343 | ||
|
|
927c7e95f5 | ||
|
|
16910fe25c | ||
|
|
c38987d94d | ||
|
|
e4132111bc | ||
|
|
211ee3027d | ||
|
|
32c195d875 | ||
|
|
f145dc04bb | ||
|
|
195fad9398 | ||
|
|
898ed5d34b | ||
|
|
60cbedc4b2 | ||
|
|
2d6a9f7db9 | ||
|
|
5dca3d8c3d | ||
|
|
37cbed722a | ||
|
|
132cffbe7c | ||
|
|
36e5ff804c | ||
|
|
eaf8ad5609 | ||
|
|
16122ad2fa | ||
|
|
d3fef85dd8 | ||
|
|
f77ac2a5e8 | ||
|
|
93ac55a65b | ||
|
|
af35debe38 | ||
|
|
58997f0654 | ||
|
|
fbfc3e30fb | ||
|
|
1d1c1367df | ||
|
|
c91b600e90 | ||
|
|
49b3c850f9 | ||
|
|
25c94af755 | ||
|
|
861a012747 |
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.19
|
||||
version: 0.11.1
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
|-------------|------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
||||
@@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
@@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
```
|
||||
|
||||
返回成功时包含试剂 UUID:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
||||
```
|
||||
@@ -98,28 +100,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
|
||||
## 试剂字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||
|------|------|------|------|------|
|
||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
|
||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||
|
||||
### unit 单位值
|
||||
|
||||
| 值 | 单位 |
|
||||
|------|------|
|
||||
| 值 | 单位 |
|
||||
| ------ | ---- |
|
||||
| `"mL"` | 毫升 |
|
||||
| `"L"` | 升 |
|
||||
| `"g"` | 克 |
|
||||
| `"L"` | 升 |
|
||||
| `"g"` | 克 |
|
||||
| `"kg"` | 千克 |
|
||||
| `"瓶"` | 瓶 |
|
||||
| `"瓶"` | 瓶 |
|
||||
|
||||
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
||||
|
||||
@@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
|
||||
```json
|
||||
[
|
||||
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
|
||||
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
|
||||
{
|
||||
"cas": "7732-18-3",
|
||||
"name": "水",
|
||||
"molecular_formula": "H2O",
|
||||
"smiles": "O",
|
||||
"stock_in_quantity": 10,
|
||||
"unit": "mL"
|
||||
},
|
||||
{
|
||||
"cas": "64-17-5",
|
||||
"name": "乙醇",
|
||||
"molecular_formula": "C2H6O",
|
||||
"smiles": "CCO",
|
||||
"stock_in_quantity": 5,
|
||||
"unit": "L"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -160,9 +176,20 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
|
||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
||||
```
|
||||
|
||||
### 日期格式规则(重要)
|
||||
|
||||
所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。
|
||||
|
||||
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
|
||||
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
|
||||
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
|
||||
|
||||
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。
|
||||
|
||||
### 执行与汇报
|
||||
|
||||
每次 API 调用后:
|
||||
|
||||
1. 检查返回 `code`(0 = 成功)
|
||||
2. 记录成功/失败数量
|
||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
||||
@@ -172,28 +199,29 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
|
||||
|
||||
## 常见试剂速查表
|
||||
|
||||
| 名称 | CAS | 分子式 | SMILES |
|
||||
|------|-----|--------|--------|
|
||||
| 水 | 7732-18-3 | H2O | O |
|
||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||
| 名称 | CAS | 分子式 | SMILES |
|
||||
| --------------------- | --------- | ---------- | ------------------------------------ |
|
||||
| 水 | 7732-18-3 | H2O | O |
|
||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
|
||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||
|
||||
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
||||
|
||||
|
||||
@@ -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. Use when the user wants to submit experiments, create notebooks, batch run workflows, or mentions 提交实验/批量实验/notebook/实验轮次.
|
||||
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
||||
---
|
||||
|
||||
# 批量提交实验指南
|
||||
# Uni-Lab 批量提交实验指南
|
||||
|
||||
通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||
通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
@@ -18,25 +20,28 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w
|
||||
生成 AUTH token(任选一种方式):
|
||||
|
||||
```bash
|
||||
# 方式一:Python 一行生成
|
||||
# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic")
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
|
||||
# 方式二:手动计算
|
||||
# base64(ak:sk) → Authorization: Lab <token>
|
||||
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
|-------------|------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
@@ -44,22 +49,23 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
|
||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
||||
|
||||
按优先级搜索:
|
||||
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
|
||||
|
||||
```
|
||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
||||
<workspace 根目录>/req_device_registry_upload.json
|
||||
Glob: **/req_device_registry_upload.json
|
||||
```
|
||||
|
||||
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
|
||||
常见位置(仅供参考,以 Glob 实际结果为准):
|
||||
- `<workspace>/unilabos_data/req_device_registry_upload.json`
|
||||
- `<workspace>/req_device_registry_upload.json`
|
||||
|
||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||
|
||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||
|
||||
### 4. workflow_uuid(目标工作流)
|
||||
|
||||
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #2 列出可用 workflow 供选择。
|
||||
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
@@ -68,8 +74,9 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**)
|
||||
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #3 获取)
|
||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取)
|
||||
|
||||
## 请求约定
|
||||
|
||||
@@ -92,12 +99,46 @@ 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`。
|
||||
|
||||
### 2. 列出可用 workflow
|
||||
### 2. 列出实验室项目(让用户选择项目)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```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
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
@@ -105,13 +146,14 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid
|
||||
|
||||
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。
|
||||
|
||||
### 3. 获取 workflow 模板详情
|
||||
### 4. 获取 workflow 模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||
|
||||
- 每个 action 节点的 `node_uuid`
|
||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||
- 每个节点的动作名(`node_template_name`)
|
||||
@@ -119,7 +161,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
|
||||
|
||||
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
|
||||
|
||||
### 4. 提交实验(创建 notebook)
|
||||
### 5. 提交实验(创建 notebook)
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
||||
@@ -131,34 +173,45 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
||||
|
||||
```json
|
||||
{
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"project_uuid": "<project_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
{
|
||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||
"datas": [
|
||||
{
|
||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||
"datas": [
|
||||
{
|
||||
"node_uuid": "<workflow中的节点UUID>",
|
||||
"param": {},
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "<容器UUID>",
|
||||
"sample_value": {
|
||||
"liquid_names": "<液体名称>",
|
||||
"volumes": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"node_uuid": "<workflow中的节点UUID>",
|
||||
"param": {},
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "<容器UUID>",
|
||||
"sample_value": {
|
||||
"liquid_names": "<液体名称>",
|
||||
"volumes": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
|
||||
|
||||
### 6. 查询 notebook 状态
|
||||
|
||||
提交成功后,使用返回的 notebook UUID 查询执行状态:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
|
||||
|
||||
---
|
||||
|
||||
## Notebook 请求体详解
|
||||
@@ -172,25 +225,25 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
||||
|
||||
### 每轮的字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 字段 | 类型 | 说明 |
|
||||
| -------------- | ------------- | ----------------------------------------- |
|
||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||
|
||||
### datas 中每个节点
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #3 获取) |
|
||||
| `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}` |
|
||||
|
||||
---
|
||||
|
||||
@@ -211,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`,包含:
|
||||
@@ -222,7 +276,7 @@ python scripts/gen_notebook_params.py \
|
||||
|
||||
如果脚本不可用或注册表不存在:
|
||||
|
||||
1. 调用 API #3 获取 workflow 详情
|
||||
1. 调用 API #4 获取 workflow 详情
|
||||
2. 找到每个 action 节点的 `node_uuid`
|
||||
3. 在本地注册表中查找对应设备的 `action_value_mappings`:
|
||||
```
|
||||
@@ -248,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"]
|
||||
}
|
||||
@@ -275,13 +332,15 @@ Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: 确认 workflow_uuid(用户提供或从 GET #2 列表选择)
|
||||
- [ ] Step 5: GET workflow detail (#3) → 提取各节点 uuid、设备ID、动作名
|
||||
- [ ] Step 6: 定位本地注册表 req_device_registry_upload.json
|
||||
- [ ] Step 7: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
||||
- [ ] Step 8: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
||||
- [ ] Step 9: 构建完整请求体 → POST /lab/notebook 提交
|
||||
- [ ] Step 10: 检查返回结果,确认提交成功
|
||||
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
|
||||
- [ ] Step 5: 确认 workflow_uuid(用户提供或从 GET #3 列表选择)
|
||||
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
|
||||
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
|
||||
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
||||
- [ ] Step 9: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
||||
- [ ] Step 10: 构建完整请求体(含 project_uuid)→ POST /lab/notebook 提交
|
||||
- [ ] Step 11: 检查返回结果,记录 notebook UUID
|
||||
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
选项:
|
||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
||||
--base <url> API 基础 URL(如 https://uni-lab.test.bohrium.com)
|
||||
--base <url> API 基础 URL(如 https://leap-lab.test.bohrium.com)
|
||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
||||
--rounds <n> 实验轮次数(默认 1)
|
||||
@@ -17,7 +17,7 @@
|
||||
示例:
|
||||
python gen_notebook_params.py \\
|
||||
--auth YTFmZDlkNGUtxxxx \\
|
||||
--base https://uni-lab.test.bohrium.com \\
|
||||
--base https://leap-lab.test.bohrium.com \\
|
||||
--workflow-uuid abc-123-def \\
|
||||
--rounds 2
|
||||
"""
|
||||
@@ -265,6 +265,7 @@ def generate_template(nodes, registry_index, rounds):
|
||||
|
||||
return {
|
||||
"lab_uuid": "$TODO_LAB_UUID",
|
||||
"project_uuid": "$TODO_PROJECT_UUID",
|
||||
"workflow_uuid": "$TODO_WORKFLOW_UUID",
|
||||
"name": "$TODO_EXPERIMENT_NAME",
|
||||
"node_params": node_params,
|
||||
|
||||
@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
|
||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||
|
||||
| `--addr` 值 | BASE URL |
|
||||
|-------------|----------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
| `--addr` 值 | BASE URL |
|
||||
| -------------- | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
|
||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||
|
||||
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
|
||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||
|
||||
| 条件 | working_dir 取值 |
|
||||
|------|------------------|
|
||||
| 条件 | working_dir 取值 |
|
||||
| -------------------- | -------------------------------------------------------- |
|
||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
|
||||
**按优先级搜索文件**:
|
||||
|
||||
@@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||
```
|
||||
|
||||
#### 完整示例
|
||||
|
||||
用户提供:
|
||||
|
||||
```
|
||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
||||
--addr test
|
||||
--port 8003
|
||||
--disable_browser
|
||||
```
|
||||
|
||||
从中提取:
|
||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
||||
|
||||
**四项全部就绪后才进入 Step 1。**
|
||||
|
||||
### Step 1 — 列出可用设备
|
||||
@@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||
|
||||
每个 action 生成一个 JSON 文件,包含:
|
||||
|
||||
- `type` — 作为 API 调用的 `action_type`
|
||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||
@@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
|
||||
### Step 3 — 写 action-index.md
|
||||
|
||||
按模板为每个 action 写条目:
|
||||
按模板为每个 action 写条目(**必须包含 `action_type`**):
|
||||
|
||||
```markdown
|
||||
### `<action_name>`
|
||||
|
||||
<用途描述(一句话)>
|
||||
|
||||
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
|
||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||
- **可选参数**: `param3`, `param4`
|
||||
@@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
```
|
||||
|
||||
描述规则:
|
||||
|
||||
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
|
||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||
- 从 `schema.required` 区分核心/可选参数
|
||||
- 按功能分类(移液、枪头、外设等)
|
||||
@@ -158,12 +144,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
||||
- `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name)
|
||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
||||
|
||||
### Step 4 — 写 SKILL.md
|
||||
|
||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||
|
||||
- 设备名称
|
||||
- Action 数量
|
||||
- 目录列表
|
||||
@@ -171,46 +159,96 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
|
||||
|
||||
API 模板结构:
|
||||
|
||||
```markdown
|
||||
## 设备信息
|
||||
|
||||
- device_id, Python 源码路径, 设备类名
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
- ak/sk → AUTH, --addr → BASE URL
|
||||
|
||||
## 请求约定
|
||||
|
||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||
|
||||
## Session State
|
||||
|
||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||
|
||||
## API Endpoints
|
||||
|
||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||
|
||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||
|
||||
# - #3 创建节点 POST /edge/workflow/node
|
||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||
# - #10 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||
|
||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||
|
||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||
|
||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||
|
||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||
|
||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||
|
||||
# - #7 批量创建边 POST /lab/workflow/edges
|
||||
|
||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||
|
||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||
|
||||
# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
|
||||
|
||||
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
||||
|
||||
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
||||
|
||||
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||
|
||||
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
||||
|
||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
||||
|
||||
# - #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" 路径字符串
|
||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
||||
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
|
||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
||||
- 列出本设备所有 Slot 字段、类型及含义
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
## 完整工作流 Checklist
|
||||
```
|
||||
|
||||
### Step 5 — 验证
|
||||
|
||||
检查文件完整性:
|
||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#9 工作流/动作、#10 资源树)
|
||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||
|
||||
- [ ] `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 文件
|
||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
||||
@@ -256,67 +294,198 @@ API 模板结构:
|
||||
|
||||
## Placeholder Slot 类型体系
|
||||
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
|
||||
`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 |
|
||||
| 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`)
|
||||
|
||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||
|
||||
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
|
||||
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
|
||||
|
||||
> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
|
||||
|
||||
### DeviceSlot / NodeSlot / ClassSlot
|
||||
|
||||
- **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
|
||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
||||
[
|
||||
{
|
||||
"sample_uuid": "",
|
||||
"well_name": "bottle_A1",
|
||||
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- 单个(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` = 目标位置)
|
||||
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
|
||||
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL)
|
||||
- `sample_uuid` — 样品 UUID,无样品传 `""`
|
||||
- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息
|
||||
|
||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
||||
|
||||
### DeviceSlot(`unilabos_devices`)
|
||||
|
||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
||||
|
||||
```
|
||||
"/host_node"
|
||||
"/bioyond_cell/reaction_station"
|
||||
```
|
||||
|
||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
||||
|
||||
### NodeSlot(`unilabos_nodes`)
|
||||
|
||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
||||
|
||||
```
|
||||
"/PRCXI/PRCXI_Deck"
|
||||
```
|
||||
|
||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
||||
|
||||
### ClassSlot(`unilabos_class`)
|
||||
|
||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
||||
|
||||
```
|
||||
"container"
|
||||
```
|
||||
|
||||
### 通过 API #10 获取资源树
|
||||
### 通过 API #12 获取资源树
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"nodes": [
|
||||
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
|
||||
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
|
||||
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
|
||||
],
|
||||
"edges": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`
|
||||
- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等)
|
||||
- `parent` 为父节点名称(空字符串表示顶级)
|
||||
- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点
|
||||
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
|
||||
|
||||
## 物料管理 API
|
||||
|
||||
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
|
||||
|
||||
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。
|
||||
|
||||
### API #14 — 按名称查询物料模板
|
||||
|
||||
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ---------- | ------ | -------------------------------- |
|
||||
| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) |
|
||||
| `name` | **是** | 物料模板名称(如 `"container"`) |
|
||||
|
||||
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。
|
||||
|
||||
模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
|
||||
|
||||
### API #15 — 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "my_custom_bottle",
|
||||
"display_name": "自定义瓶子",
|
||||
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"type": "",
|
||||
"init_param_data": {},
|
||||
"schema": {},
|
||||
"data": {
|
||||
"liquids": [["water", 1000, "uL"]],
|
||||
"max_volume": 50000
|
||||
},
|
||||
"plate_well_datas": {},
|
||||
"plate_reagent_datas": {},
|
||||
"pose": {},
|
||||
"model": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
|
||||
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
|
||||
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
|
||||
| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) |
|
||||
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
|
||||
| `type` | 否 | string | 从模板继承 | 节点类型 |
|
||||
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
|
||||
| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 |
|
||||
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
|
||||
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
|
||||
| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 |
|
||||
| `pose` | 否 | object | 用户指定 | 位姿信息 |
|
||||
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
|
||||
|
||||
#### container 的 `data` 格式
|
||||
|
||||
> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
|
||||
"max_volume": 50000
|
||||
}
|
||||
```
|
||||
|
||||
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
|
||||
- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL
|
||||
|
||||
### API #16 — 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"display_name": "新显示名称",
|
||||
"description": "新描述",
|
||||
"init_param_data": {},
|
||||
"data": {},
|
||||
"pose": {},
|
||||
"schema": {},
|
||||
"extra": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
|
||||
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
|
||||
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
|
||||
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
|
||||
| `description` | 否 | string | 用户指定 | 更新描述 |
|
||||
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
|
||||
| `data` | 否 | object | 用户指定 | 更新节点数据 |
|
||||
| `pose` | 否 | object | 用户指定 | 更新位姿 |
|
||||
| `schema` | 否 | object | 用户指定 | 更新 schema |
|
||||
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
|
||||
|
||||
> 只传需要更新的字段,未传的字段保持不变。
|
||||
|
||||
## 最终目录结构
|
||||
|
||||
|
||||
450
.cursor/skills/filter-workflow-by-tags/SKILL.md
Normal file
450
.cursor/skills/filter-workflow-by-tags/SKILL.md
Normal file
@@ -0,0 +1,450 @@
|
||||
---
|
||||
name: filter-workflow-by-tags
|
||||
description: Query backend workflow list, aggregate all tags, and filter workflows by domain/scenario requirements using tags. Use when the user wants to search workflows, find workflows by tags, list available workflow tags, filter workflows by category/domain/scenario, or mentions 工作流筛选/标签查询/workflow tags/按领域查找工作流.
|
||||
---
|
||||
# Uni-Lab 工作流标签筛选指南
|
||||
|
||||
通过 Uni-Lab 云端 API 查询工作流列表,汇总所有可用标签(tags),并根据领域和场景要求筛选工作流。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
|
||||
## 使用模式识别
|
||||
|
||||
**用户可能一开始就给出场景目标**(如"我要做有机合成实验"、"找柱层析相关的 protocol")。此时:
|
||||
|
||||
1. **识别场景关键词** → 映射到可能的 tags(如 synthesis、organic、chromatography、purification)
|
||||
2. **直接执行完整流程**(获取 ak/sk/addr → 拉取所有工作流 → 汇总 tags → 按场景筛选)
|
||||
3. **展示筛选结果** → 引导用户从候选 workflow 中**选择明确的实验 protocol**
|
||||
4. **如果用户确认某个 workflow** → 记录 `workflow_uuid`,准备对接“与其他 Skill 的协作”
|
||||
|
||||
**如果用户未给场景目标**,则按标准 checklist 询问筛选条件。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token:
|
||||
|
||||
```bash
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------- | ------------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
### 3. lab_uuid(实验室 UUID)
|
||||
|
||||
如果用户未提供 `lab_uuid`,通过以下 API 自动获取:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 即为 `lab_uuid`。
|
||||
|
||||
**三项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID
|
||||
- `all_workflows` — 完整工作流列表(分页获取后缓存到内存或临时文件)
|
||||
- `all_tags` — 所有工作流的标签汇总
|
||||
|
||||
---
|
||||
|
||||
## API 端点
|
||||
|
||||
### 查询工作流列表(支持分页)
|
||||
|
||||
```
|
||||
GET $BASE/api/v1/lab/workflow/owner/list?page=<page>&page_size=<page_size>&lab_uuid=$lab_uuid
|
||||
```
|
||||
|
||||
**参数:**
|
||||
|
||||
- `page` — 页码,从 1 开始
|
||||
- `page_size` — 每页数量,建议 1000
|
||||
- `lab_uuid` — 实验室 UUID
|
||||
|
||||
**返回结构:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"has_more": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "9661bba2-1b9f-4687-a63d-910245df174b",
|
||||
"name": "Untitled",
|
||||
"description": "",
|
||||
"user_id": "114211",
|
||||
"published": false,
|
||||
"tags": null
|
||||
},
|
||||
{
|
||||
"uuid": "e0436638-190b-46bc-b1a1-2711d9602f6a",
|
||||
"name": "Synthesis v2",
|
||||
"user_id": "114211",
|
||||
"published": true,
|
||||
"tags": ["synthesis", "organic"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
- `has_more` — 若为 `true`,需要继续请求 `page+1`
|
||||
- `tags` — 可能为 `null`、空数组或字符串数组;聚合时必须容忍 `null`
|
||||
|
||||
### 启动工作流(直接运行)
|
||||
|
||||
```
|
||||
POST $BASE/api/v1/lab/workflow/<workflow_uuid>/run
|
||||
```
|
||||
|
||||
**用途:** 直接启动一个 workflow 的默认执行(使用模板中预设的参数),无需创建 notebook。适用于快速测试或无参数变化的重复执行。
|
||||
|
||||
**请求体:** 空 JSON `{}` 或省略
|
||||
|
||||
**返回:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": "<run_uuid>"
|
||||
}
|
||||
```
|
||||
|
||||
- `run_uuid` — 本次执行的唯一标识(不是 notebook UUID)
|
||||
|
||||
**注意:**
|
||||
|
||||
- 该接口会使用 workflow 模板中保存的默认参数直接执行
|
||||
- 如果 workflow 需要动态参数(如 CSV 路径、样品 UUID),应使用 `POST /lab/notebook` 创建 notebook 并传入 `node_params`
|
||||
- 返回的 `run_uuid` 可直接传入下方「查询任务状态」接口查询实时进度
|
||||
|
||||
### 查询任务状态
|
||||
|
||||
```
|
||||
GET $BASE/api/v1/lab/mcp/task/<task_uuid>
|
||||
```
|
||||
|
||||
**用途:** 查询由 `POST /lab/workflow/<uuid>/run` 返回的 `run_uuid`(即 task_uuid)的实时执行状态,包括整体状态和每个节点(JOS:Job On Station)的执行详情。
|
||||
|
||||
**路径参数:**
|
||||
|
||||
- `task_uuid` — 等同于启动工作流接口返回的 `run_uuid`
|
||||
|
||||
**返回:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"status": "running",
|
||||
"jos_status": [
|
||||
{
|
||||
"uuid": "d0e24bfe-8d99-450e-b19d-f25849dfbaad",
|
||||
"node_name": "PRCXI_BioER_96_wellplate_slot_1",
|
||||
"action_name": "create_resource",
|
||||
"status": "success",
|
||||
"return_info": {
|
||||
"suc": true,
|
||||
"error": "",
|
||||
"return_value": { ... }
|
||||
}
|
||||
},
|
||||
{
|
||||
"uuid": "...",
|
||||
"node_name": "...",
|
||||
"action_name": "transfer_liquid",
|
||||
"status": "pending",
|
||||
"return_info": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
- `data.status` — 整体任务状态
|
||||
- `running` — 正在执行(至少一个节点 pending 或 running)
|
||||
- `success` — 全部节点成功
|
||||
- `failed` — 有节点失败
|
||||
- `data.jos_status[]` — 节点级执行列表(按执行顺序)
|
||||
- `uuid` — 节点执行实例 UUID
|
||||
- `node_name` — 节点名称(资源/设备名或工位名)
|
||||
- `action_name` — 动作类型(`create_resource`、`transfer_liquid`、`centrifuge`、等)
|
||||
- `status` — 节点状态:`success`、`pending`、`running`、`failed`
|
||||
- `return_info` — 执行返回,失败时 `suc=false` 且 `error` 有错误信息
|
||||
|
||||
**注意:**
|
||||
|
||||
- 此接口的 `task_uuid` **是** `POST /lab/workflow/<uuid>/run` 返回的 `run_uuid`,二者为同一个 ID 的不同称呼
|
||||
- **不要**把 notebook UUID(`POST /lab/notebook` 返回)传进来——那条路径用 `GET /lab/notebook/status` 查询
|
||||
- `jos_status` 数组按节点执行顺序给出;从 pending 数量可以估算剩余进度
|
||||
- 返回体可能较大(`return_info.return_value` 中可能包含完整 resource tree),可在脚本中只提取 `status` + `node_name` + `action_name` 做摘要
|
||||
|
||||
**状态轮询示例:**
|
||||
|
||||
```bash
|
||||
# 每 5 秒轮询一次直至完成
|
||||
TASK="b183d97e-d2b5-4b24-b14b-820df04d87c0"
|
||||
while :; do
|
||||
st=$(curl -s -X GET "$BASE/api/v1/lab/mcp/task/$TASK" -H "$AUTH" \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; \
|
||||
print(d['status'], '|', sum(1 for j in d['jos_status'] if j['status']=='success'), '/', len(d['jos_status']))")
|
||||
echo "$(date +%H:%M:%S) $st"
|
||||
[[ "$st" == success* || "$st" == failed* ]] && break
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 0: 识别用户是否已给出场景目标(如"有机合成"、"柱层析")
|
||||
- 若已给出 → 记录场景关键词,自动进入后续步骤
|
||||
- 若未给出 → 在 Step 6 询问用户
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid(如用户未提供)
|
||||
- [ ] Step 4: 分页获取所有工作流(从 page=1 开始直到 has_more=false)
|
||||
- [ ] Step 5: 汇总所有非空 tags → 生成 all_tags(去重、排序、附出现次数)
|
||||
- [ ] Step 6: 根据场景关键词(Step 0 或新询问)在 all_tags 中做语义映射 → 确定候选 tags
|
||||
- 若语义映射不唯一,列出候选 tags 让用户确认
|
||||
- [ ] Step 7: 按候选 tags 筛选工作流(默认 any 模式,召回优先)
|
||||
- [ ] Step 8: 展示筛选结果(uuid、name、description、tags、published)
|
||||
- [ ] Step 9: 引导用户从结果中选择**明确的实验 protocol**
|
||||
- 若结果只有 1 条 → 直接确认该 workflow_uuid
|
||||
- 若结果 2–10 条 → 让用户按编号选择
|
||||
- 若结果过多 → 提示收紧条件(加 tag、切换 all 模式、仅 published)
|
||||
- 若结果为空 → 放宽条件(去掉最稀有 tag)或提示用户换关键词
|
||||
- [ ] Step 10: 记录用户选中的 workflow_uuid,并提示提交实验或查看详情
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 推荐路径:使用脚本
|
||||
|
||||
同目录下提供 `scripts/filter_workflows.py`,一次完成分页抓取、标签聚合与筛选:
|
||||
|
||||
```bash
|
||||
# 1. 仅汇总标签(不筛选)
|
||||
python scripts/filter_workflows.py \
|
||||
--auth "<Lab base64token>" \
|
||||
--base "$BASE" \
|
||||
--lab-uuid "$lab_uuid" \
|
||||
--summary-only
|
||||
|
||||
# 2. 按标签筛选(ANY 模式:包含任一)
|
||||
python scripts/filter_workflows.py \
|
||||
--auth "<Lab base64token>" \
|
||||
--base "$BASE" \
|
||||
--lab-uuid "$lab_uuid" \
|
||||
--tags synthesis organic \
|
||||
--mode any
|
||||
|
||||
# 3. 按标签筛选(ALL 模式:必须同时包含)
|
||||
python scripts/filter_workflows.py \
|
||||
--auth "<Lab base64token>" \
|
||||
--base "$BASE" \
|
||||
--lab-uuid "$lab_uuid" \
|
||||
--tags synthesis organic \
|
||||
--mode all \
|
||||
--output filtered.json
|
||||
|
||||
# 4. 仅筛选已发布
|
||||
python scripts/filter_workflows.py \
|
||||
--auth "<Lab base64token>" \
|
||||
--base "$BASE" \
|
||||
--lab-uuid "$lab_uuid" \
|
||||
--tags synthesis \
|
||||
--published-only
|
||||
```
|
||||
|
||||
**`--auth` 参数说明**:传入 `Authorization` 头中 `Lab` 之后的 base64 token(不带 `Lab ` 前缀),脚本内部会自动补上 scheme。
|
||||
|
||||
**输出结构:**
|
||||
|
||||
```json
|
||||
{
|
||||
"total_workflows": 150,
|
||||
"tag_counts": {"synthesis": 12, "organic": 8, "analysis": 5},
|
||||
"all_tags": ["analysis", "organic", "synthesis"],
|
||||
"filter": {"tags": ["synthesis", "organic"], "mode": "any"},
|
||||
"filtered_workflows": [
|
||||
{"uuid": "...", "name": "...", "description": "...", "tags": [...], "published": true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 手动路径:curl + jq
|
||||
|
||||
如果脚本不可用或环境缺少 Python,可用 shell 实现。
|
||||
|
||||
### 1. 分页抓取(写入 `all_workflows.json`)
|
||||
|
||||
```bash
|
||||
page=1
|
||||
echo "[]" > all_workflows.json
|
||||
|
||||
while :; do
|
||||
resp=$(curl -s -X GET \
|
||||
"$BASE/api/v1/lab/workflow/owner/list?page=$page&page_size=1000&lab_uuid=$lab_uuid" \
|
||||
-H "$AUTH")
|
||||
|
||||
page_data=$(echo "$resp" | jq -c '.data.data // []')
|
||||
jq -c --argjson p "$page_data" '. + $p' all_workflows.json > _tmp.json && mv _tmp.json all_workflows.json
|
||||
|
||||
has_more=$(echo "$resp" | jq -r '.data.has_more')
|
||||
[ "$has_more" != "true" ] && break
|
||||
page=$((page + 1))
|
||||
done
|
||||
|
||||
echo "Total: $(jq 'length' all_workflows.json)"
|
||||
```
|
||||
|
||||
### 2. 汇总所有标签(含出现次数)
|
||||
|
||||
```bash
|
||||
jq '[.[].tags // [] | .[]] | group_by(.) | map({tag: .[0], count: length}) | sort_by(-.count)' \
|
||||
all_workflows.json
|
||||
```
|
||||
|
||||
### 3. 按标签筛选
|
||||
|
||||
```bash
|
||||
# ANY:包含任一指定标签
|
||||
jq --argjson want '["synthesis","organic"]' \
|
||||
'[.[] | select((.tags // []) | any(. as $t | $want | index($t)))]' \
|
||||
all_workflows.json
|
||||
|
||||
# ALL:同时包含所有指定标签
|
||||
jq --argjson want '["synthesis","organic"]' \
|
||||
'[.[] | select(($want | all(. as $w | (.tags // []) | index($w))))]' \
|
||||
all_workflows.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 筛选策略
|
||||
|
||||
agent 拿到用户的「领域 + 场景」自然语言描述时,按如下顺序选择 tag:
|
||||
|
||||
1. **优先用户显式指定的 tags**:若用户明确给出标签词,直接精确匹配。
|
||||
2. **从 all_tags 中做语义映射**:若用户描述是自然语言(如"有机合成、柱层析"),在 all_tags 中找语义相关项(如 `synthesis`、`organic`、`chromatography`)。必要时展示候选 tag 让用户确认。
|
||||
3. **模式选择**:
|
||||
- 默认 `any`(更多召回),给出 tag 集合的并集匹配
|
||||
- 用户强调"必须同时满足"时用 `all`
|
||||
4. **空结果兜底**:如果筛选为空,放宽条件(去掉最稀有 tag、切换 any 模式),并提醒用户。
|
||||
|
||||
---
|
||||
|
||||
## 引导到明确的 Protocol
|
||||
|
||||
筛选完成后,**最终目标是让用户确认一个具体的 workflow_uuid**,而不是停留在"一堆候选"上。按结果数量采取不同策略:
|
||||
|
||||
| 结果数量 | 策略 |
|
||||
| --------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 条 | 放宽筛选(去掉最稀有 tag → 切换 any 模式 → 去掉 `--published-only`)。仍为空则提示换关键词,或列出 `all_tags` 让用户重新选。 |
|
||||
| 1 条 | 直接确认:"找到唯一匹配:`<name>` (uuid `<uuid>`),是否用它?"用户确认后记录 `workflow_uuid`。 |
|
||||
| 2–10 条 | 编号列表展示,让用户选编号。每项给出 name、tags、description 摘要、published 状态。 |
|
||||
| 10–30 条 | 先展示 tag 分布帮助用户进一步收紧:列出匹配结果中最常见的子标签,提示"加一个 tag 可将结果缩小到 N 条"。 |
|
||||
| >30 条 | 强制要求用户补充条件:仅 published、指定具体 tag 组合、或按名称关键词过滤。 |
|
||||
|
||||
**确认 workflow 后**:
|
||||
|
||||
1. 将 `workflow_uuid` 写入 session state
|
||||
2. 提示用户下一步可用的 skill:
|
||||
- 提交实验 → 引导到“与其他 Skill 的协作”
|
||||
- 查看 workflow 详细节点 → `GET /api/v1/lab/workflow/template/detail/<workflow_uuid>`
|
||||
3. 若用户想换一个,回到筛选步骤。
|
||||
|
||||
---
|
||||
|
||||
## 展示结果
|
||||
|
||||
推荐格式(表格 + 汇总统计):
|
||||
|
||||
```
|
||||
共 150 个工作流,其中 32 个匹配筛选条件 [tags: synthesis OR organic]
|
||||
|
||||
| UUID (短) | 名称 | Tags | 已发布 |
|
||||
|-----------|--------------------------|------------------------------|--------|
|
||||
| e0436638 | Synthesis v2 | synthesis, organic | ✓ |
|
||||
| 5b60dbb8 | Grignard Protocol | synthesis, organometallic | ✓ |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
所有可用标签(按频次):
|
||||
synthesis (12), organic (8), analysis (5), purification (4), ...
|
||||
```
|
||||
|
||||
如果用户下一步想执行某工作流 → 引导到“与其他 Skill 的协作”。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: tags 为 null 的工作流要不要展示?
|
||||
|
||||
默认**不展示**在筛选结果中(因为无法按 tag 匹配)。但在 `--summary-only` 或无筛选条件时,这些工作流仍会计入总数,并在输出中单独列出"未打标签"计数。
|
||||
|
||||
### Q: 如何按名称/描述做模糊匹配?
|
||||
|
||||
脚本未内置,但可在 jq 中组合:
|
||||
|
||||
```bash
|
||||
jq '[.[] | select((.name + " " + (.description // "")) | test("organic"; "i"))]' all_workflows.json
|
||||
```
|
||||
|
||||
### Q: `page_size=1000` 是否会被服务端限制?
|
||||
|
||||
接口通常允许最大 1000;如果返回量少于 1000 且 `has_more=false`,说明已到末页。极端情况下若服务端返回错误,可降到 200 或 500 再试。
|
||||
|
||||
### Q: 工作流数量极大(>10k)怎么办?
|
||||
|
||||
1. 先跑 `--summary-only` 了解 tag 分布
|
||||
2. 提示用户先限定 `--published-only` 或指定 tag
|
||||
3. 考虑将 `all_workflows.json` 缓存到本地,下次直接复用
|
||||
|
||||
---
|
||||
|
||||
## 与其他 Skill 的协作
|
||||
|
||||
- 正常情况下,找到 workflow 之后可以直接用它提交实验(启动工作流的 api 端点 POST $BASE/api/v1/lab/workflow/<workflow_uuid>/run,不用别的 skill)
|
||||
- **仅当需要进行多次实验时,使用 batch-submit-experiment** — 筛选到目标工作流后,`workflow_uuid` 直接用于实验提交
|
||||
|
||||
## 脚本依赖
|
||||
|
||||
`scripts/filter_workflows.py` 仅使用 Python 标准库(`urllib`、`json`、`argparse`),无需额外安装。
|
||||
191
.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py
Executable file
191
.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py
Executable file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""分页拉取 Uni-Lab 工作流列表,汇总 tags 并按 tag 筛选。
|
||||
|
||||
使用示例:
|
||||
python filter_workflows.py \
|
||||
--auth <base64token> \
|
||||
--base https://leap-lab.test.bohrium.com \
|
||||
--lab-uuid a9059772-... \
|
||||
--tags synthesis organic --mode any
|
||||
|
||||
仅依赖 Python 标准库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
|
||||
|
||||
def fetch_all_workflows(base: str, auth_token: str, lab_uuid: str, page_size: int = 1000) -> list[dict]:
|
||||
"""分页拉取所有 owner 工作流,直到 has_more=false。"""
|
||||
workflows: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
query = urllib.parse.urlencode(
|
||||
{"page": page, "page_size": page_size, "lab_uuid": lab_uuid}
|
||||
)
|
||||
url = f"{base.rstrip('/')}/api/v1/lab/workflow/owner/list?{query}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Lab {auth_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit(f"[ERROR] HTTP {e.code} on page {page}: {e.read().decode('utf-8', 'ignore')}")
|
||||
except urllib.error.URLError as e:
|
||||
sys.exit(f"[ERROR] URL error on page {page}: {e.reason}")
|
||||
|
||||
if payload.get("code") != 0:
|
||||
sys.exit(f"[ERROR] API returned non-zero code: {payload}")
|
||||
|
||||
data = payload.get("data") or {}
|
||||
page_items = data.get("data") or []
|
||||
workflows.extend(page_items)
|
||||
|
||||
if not data.get("has_more"):
|
||||
break
|
||||
page += 1
|
||||
# 防御性兜底,避免接口异常导致无限循环
|
||||
if page > 1000:
|
||||
print(f"[WARN] page count exceeded 1000, stopping early", file=sys.stderr)
|
||||
break
|
||||
|
||||
return workflows
|
||||
|
||||
|
||||
def aggregate_tags(workflows: list[dict]) -> tuple[list[str], dict[str, int], int]:
|
||||
"""返回 (sorted_tags, tag_counts, untagged_count)。"""
|
||||
counter: Counter[str] = Counter()
|
||||
untagged = 0
|
||||
for wf in workflows:
|
||||
tags = wf.get("tags")
|
||||
if not tags:
|
||||
untagged += 1
|
||||
continue
|
||||
for t in tags:
|
||||
if isinstance(t, str) and t.strip():
|
||||
counter[t.strip()] += 1
|
||||
return sorted(counter.keys()), dict(counter), untagged
|
||||
|
||||
|
||||
def filter_workflows(
|
||||
workflows: list[dict],
|
||||
want_tags: list[str],
|
||||
mode: str,
|
||||
published_only: bool,
|
||||
) -> list[dict]:
|
||||
"""按 tag 筛选。mode 取值 any / all。"""
|
||||
want_set = {t.strip() for t in want_tags if t.strip()}
|
||||
out: list[dict] = []
|
||||
for wf in workflows:
|
||||
if published_only and not wf.get("published"):
|
||||
continue
|
||||
if not want_set:
|
||||
out.append(wf)
|
||||
continue
|
||||
tags = wf.get("tags") or []
|
||||
tag_set = {t for t in tags if isinstance(t, str)}
|
||||
if mode == "all":
|
||||
if want_set.issubset(tag_set):
|
||||
out.append(wf)
|
||||
else: # any
|
||||
if want_set & tag_set:
|
||||
out.append(wf)
|
||||
return out
|
||||
|
||||
|
||||
def project_workflow(wf: dict) -> dict:
|
||||
"""精简输出字段。"""
|
||||
return {
|
||||
"uuid": wf.get("uuid"),
|
||||
"name": wf.get("name"),
|
||||
"description": wf.get("description", ""),
|
||||
"tags": wf.get("tags") or [],
|
||||
"published": bool(wf.get("published")),
|
||||
"user_id": wf.get("user_id"),
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Fetch & filter Uni-Lab workflows by tags.")
|
||||
p.add_argument("--auth", required=True, help="Base64 token (the part after `Lab `).")
|
||||
p.add_argument("--base", required=True, help="Base URL, e.g. https://leap-lab.test.bohrium.com")
|
||||
p.add_argument("--lab-uuid", required=True, help="Lab UUID.")
|
||||
p.add_argument("--tags", nargs="*", default=[], help="Tags to filter by (space separated).")
|
||||
p.add_argument(
|
||||
"--mode",
|
||||
choices=["any", "all"],
|
||||
default="any",
|
||||
help="any: workflow contains at least one tag; all: workflow contains every tag.",
|
||||
)
|
||||
p.add_argument("--published-only", action="store_true", help="Only include published workflows.")
|
||||
p.add_argument("--page-size", type=int, default=1000, help="Page size, default 1000.")
|
||||
p.add_argument(
|
||||
"--summary-only",
|
||||
action="store_true",
|
||||
help="Print tag summary without applying filter (still fetches everything).",
|
||||
)
|
||||
p.add_argument("--output", help="Write JSON result to this path. If omitted, print to stdout.")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
workflows = fetch_all_workflows(
|
||||
base=args.base,
|
||||
auth_token=args.auth,
|
||||
lab_uuid=args.lab_uuid,
|
||||
page_size=args.page_size,
|
||||
)
|
||||
sorted_tags, tag_counts, untagged = aggregate_tags(workflows)
|
||||
|
||||
if args.summary_only:
|
||||
result = {
|
||||
"total_workflows": len(workflows),
|
||||
"untagged_count": untagged,
|
||||
"tag_counts": tag_counts,
|
||||
"all_tags": sorted_tags,
|
||||
}
|
||||
else:
|
||||
filtered = filter_workflows(
|
||||
workflows,
|
||||
want_tags=args.tags,
|
||||
mode=args.mode,
|
||||
published_only=args.published_only,
|
||||
)
|
||||
result = {
|
||||
"total_workflows": len(workflows),
|
||||
"untagged_count": untagged,
|
||||
"tag_counts": tag_counts,
|
||||
"all_tags": sorted_tags,
|
||||
"filter": {
|
||||
"tags": args.tags,
|
||||
"mode": args.mode,
|
||||
"published_only": args.published_only,
|
||||
},
|
||||
"matched_count": len(filtered),
|
||||
"filtered_workflows": [project_workflow(wf) for wf in filtered],
|
||||
}
|
||||
|
||||
payload = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(payload)
|
||||
print(f"Wrote {len(workflows)} workflows summary → {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
251
.cursor/skills/host-node/SKILL.md
Normal file
251
.cursor/skills/host-node/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: host-node
|
||||
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
|
||||
---
|
||||
|
||||
# Host Node API Skill
|
||||
|
||||
## 设备信息
|
||||
|
||||
- **device_id**: `host_node`
|
||||
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
|
||||
- **设备类**: `HostNode`
|
||||
- **动作数**: 4(`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`)
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `device_name` — `host_node`
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||
|
||||
### 2. 创建工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||
|
||||
### 3. 创建节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
|
||||
```
|
||||
|
||||
- `resource_template_name` 固定为 `host_node`
|
||||
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`)
|
||||
|
||||
### 4. 删除节点
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||
```
|
||||
|
||||
### 5. 更新节点参数
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||
|
||||
### 6. 查询节点 handles
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||
```
|
||||
|
||||
### 7. 批量创建边
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||
```
|
||||
|
||||
### 8. 启动工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 9. 运行设备单动作
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||
|
||||
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||
|
||||
#### action_type 速查表
|
||||
|
||||
| action | action_type |
|
||||
|--------|-------------|
|
||||
| `test_latency` | `UniLabJsonCommand` |
|
||||
| `create_resource` | `ResourceCreateFromOuterEasy` |
|
||||
| `auto-test_resource` | `UniLabJsonCommand` |
|
||||
| `manual_confirm` | `UniLabJsonCommand` |
|
||||
|
||||
### 10. 查询任务状态
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 11. 运行工作流单节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuid":"<node_uuid>"}'
|
||||
```
|
||||
|
||||
### 12. 获取资源树(物料信息)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||
|
||||
### 13. 获取工作流模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||
|
||||
### 14. 按名称查询物料模板
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||
|
||||
### 15. 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||
```
|
||||
|
||||
### 16. 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||
|
||||
### host_node 设备的 Slot 字段表
|
||||
|
||||
| Action | 字段 | Slot 类型 | 说明 |
|
||||
| ----------------- | ----------- | ------------ | ------------------------------ |
|
||||
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
|
||||
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
|
||||
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
|
||||
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
|
||||
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
|
||||
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
|
||||
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
|
||||
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
|
||||
|
||||
---
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
1. **SKILL.md**(本文件)— API 端点 + session state 管理
|
||||
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
|
||||
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
|
||||
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||
```
|
||||
58
.cursor/skills/host-node/action-index.md
Normal file
58
.cursor/skills/host-node/action-index.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Action Index — host_node
|
||||
|
||||
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||
|
||||
---
|
||||
|
||||
## 资源管理
|
||||
|
||||
### `create_resource`
|
||||
|
||||
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
|
||||
|
||||
- **action_type**: `ResourceCreateFromOuterEasy`
|
||||
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
|
||||
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
|
||||
- **占位符字段**:
|
||||
- `res_id` — **ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
|
||||
- `device_id` — **DeviceSlot**,填路径字符串如 `"/host_node"`
|
||||
- `parent` — **NodeSlot**,填路径字符串如 `"/workstation/deck"`
|
||||
- `class_name` — **ClassSlot**,填类名如 `"container"`
|
||||
|
||||
### `auto-test_resource`
|
||||
|
||||
测试资源系统,返回当前资源树和设备列表
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
|
||||
- **可选参数**: `resource`, `resources`, `device`, `devices`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,单个物料节点 `{id, name, uuid}`
|
||||
- `resources` — **ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
|
||||
- `device` — **DeviceSlot**,设备路径字符串
|
||||
- `devices` — **DeviceSlot**,设备路径字符串
|
||||
|
||||
---
|
||||
|
||||
## 系统工具
|
||||
|
||||
### `test_latency`
|
||||
|
||||
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
|
||||
- **参数**: 无(零参数调用)
|
||||
|
||||
---
|
||||
|
||||
## 人工确认
|
||||
|
||||
### `manual_confirm`
|
||||
|
||||
创建人工确认节点,等待用户手动确认后继续
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
|
||||
- **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||
93
.cursor/skills/host-node/actions/create_resource.json
Normal file
93
.cursor/skills/host-node/actions/create_resource.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"type": "ResourceCreateFromOuterEasy",
|
||||
"goal": {
|
||||
"res_id": "res_id",
|
||||
"class_name": "class_name",
|
||||
"parent": "parent",
|
||||
"device_id": "device_id",
|
||||
"bind_locations": "bind_locations",
|
||||
"liquid_input_slot": "liquid_input_slot[]",
|
||||
"liquid_type": "liquid_type[]",
|
||||
"liquid_volume": "liquid_volume[]",
|
||||
"slot_on_deck": "slot_on_deck"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"res_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"class_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"bind_locations": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "bind_locations",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"liquid_input_slot": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"liquid_type": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"liquid_volume": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"slot_on_deck": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"_unilabos_placeholder_info": {
|
||||
"res_id": "unilabos_resources",
|
||||
"device_id": "unilabos_devices",
|
||||
"parent": "unilabos_nodes",
|
||||
"class_name": "unilabos_class"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"res_id": "unilabos_resources",
|
||||
"device_id": "unilabos_devices",
|
||||
"parent": "unilabos_nodes",
|
||||
"class_name": "unilabos_class"
|
||||
}
|
||||
}
|
||||
32
.cursor/skills/host-node/actions/manual_confirm.json
Normal file
32
.cursor/skills/host-node/actions/manual_confirm.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"timeout_seconds": "timeout_seconds",
|
||||
"assignee_user_ids": "assignee_user_ids"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeout_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"assignee_user_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"timeout_seconds",
|
||||
"assignee_user_ids"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
}
|
||||
11
.cursor/skills/host-node/actions/test_latency.json
Normal file
11
.cursor/skills/host-node/actions/test_latency.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
255
.cursor/skills/host-node/actions/test_resource.json
Normal file
255
.cursor/skills/host-node/actions/test_resource.json
Normal file
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"resources": "resources",
|
||||
"device": "device",
|
||||
"devices": "devices"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"resources": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resources"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"devices": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"resources": "unilabos_resources",
|
||||
"device": "unilabos_devices",
|
||||
"devices": "unilabos_devices"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"resources": "unilabos_resources",
|
||||
"device": "unilabos_devices",
|
||||
"devices": "unilabos_devices"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
name: submit-agent-result
|
||||
description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
||||
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
||||
---
|
||||
|
||||
# 提交历史实验记录指南
|
||||
# Uni-Lab 提交历史实验记录指南
|
||||
|
||||
通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
@@ -18,23 +20,26 @@ description: Submit historical experiment results (agent_result) to Uni-Lab note
|
||||
生成 AUTH token:
|
||||
|
||||
```bash
|
||||
# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
```
|
||||
|
||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`。
|
||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
|-------------|------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
@@ -45,6 +50,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||
|
||||
如果用户不记得,可提示:
|
||||
|
||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||
- 或通过平台页面查找对应的 notebook
|
||||
|
||||
@@ -54,11 +60,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,
|
||||
|
||||
用户需要提供实验结果数据,支持以下方式:
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||
| 方式 | 说明 |
|
||||
| --------- | ----------------------------------------------- |
|
||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
@@ -90,7 +96,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
@@ -121,42 +127,45 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
|
||||
#### 必要字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------------- | ------------- | ------------------------------------------- |
|
||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||
|
||||
#### agent_result 内容格式
|
||||
|
||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||
|
||||
**简单键值对**:
|
||||
|
||||
```json
|
||||
{
|
||||
"avg_rtt_ms": 12.5,
|
||||
"status": "success",
|
||||
"test_count": 5
|
||||
"avg_rtt_ms": 12.5,
|
||||
"status": "success",
|
||||
"test_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**包含嵌套结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {"total": 100, "passed": 98, "failed": 2},
|
||||
"measurements": [
|
||||
{"sample_id": "S001", "value": 3.14, "unit": "mg/mL"},
|
||||
{"sample_id": "S002", "value": 2.71, "unit": "mg/mL"}
|
||||
]
|
||||
"summary": { "total": 100, "passed": 98, "failed": 2 },
|
||||
"measurements": [
|
||||
{ "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
|
||||
{ "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**从 CSV 文件导入**(脚本自动转换):
|
||||
|
||||
```json
|
||||
{
|
||||
"experiment_data": [
|
||||
{"温度": 25, "压力": 101.3, "产率": 0.85},
|
||||
{"温度": 30, "压力": 101.3, "产率": 0.91}
|
||||
]
|
||||
"experiment_data": [
|
||||
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
|
||||
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -178,22 +187,22 @@ python scripts/prepare_agent_result.py \
|
||||
[--output <output.json>]
|
||||
```
|
||||
|
||||
| 参数 | 必选 | 说明 |
|
||||
|------|------|------|
|
||||
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
||||
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
||||
| `--auth` | 提交时必选 | Lab 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 <base64(ak:sk)>` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||
|
||||
272
.cursor/skills/virtual-workbench/SKILL.md
Normal file
272
.cursor/skills/virtual-workbench/SKILL.md
Normal file
@@ -0,0 +1,272 @@
|
||||
---
|
||||
name: virtual-workbench
|
||||
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
|
||||
---
|
||||
|
||||
# Virtual Workbench API Skill
|
||||
|
||||
## 设备信息
|
||||
|
||||
- **device_id**: `virtual_workbench`
|
||||
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
|
||||
- **设备类**: `VirtualWorkbench`
|
||||
- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`)
|
||||
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行)
|
||||
|
||||
### 典型工作流程
|
||||
|
||||
1. `prepare_materials` — 生成 A1-A5 物料(5 个 output handle)
|
||||
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
|
||||
3. `start_heating` — 启动加热(3 个加热台可并行)
|
||||
4. `move_to_output` — 加热完成后移到输出位置 Cn
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `device_name` — `virtual_workbench`
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||
|
||||
### 2. 创建工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||
|
||||
### 3. 创建节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
|
||||
```
|
||||
|
||||
- `resource_template_name` 固定为 `virtual_workbench`
|
||||
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`)
|
||||
|
||||
### 4. 删除节点
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||
```
|
||||
|
||||
### 5. 更新节点参数
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||
```
|
||||
|
||||
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||
|
||||
### 6. 查询节点 handles
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||
```
|
||||
|
||||
### 7. 批量创建边
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||
```
|
||||
|
||||
### 8. 启动工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 9. 运行设备单动作
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||
|
||||
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||
|
||||
#### action_type 速查表
|
||||
|
||||
| action | action_type |
|
||||
|--------|-------------|
|
||||
| `auto-prepare_materials` | `UniLabJsonCommand` |
|
||||
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
|
||||
| `auto-start_heating` | `UniLabJsonCommand` |
|
||||
| `auto-move_to_output` | `UniLabJsonCommand` |
|
||||
| `transfer` | `UniLabJsonCommandAsync` |
|
||||
| `manual_confirm` | `UniLabJsonCommand` |
|
||||
|
||||
### 10. 查询任务状态
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 11. 运行工作流单节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuid":"<node_uuid>"}'
|
||||
```
|
||||
|
||||
### 12. 获取资源树(物料信息)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||
|
||||
### 13. 获取工作流模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||
|
||||
### 14. 按名称查询物料模板
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||
|
||||
### 15. 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||
```
|
||||
|
||||
### 16. 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||
|
||||
### virtual_workbench 设备的 Slot 字段表
|
||||
|
||||
| Action | 字段 | Slot 类型 | 说明 |
|
||||
| ----------------- | ---------------- | ------------ | -------------------- |
|
||||
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
|
||||
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
|
||||
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
|
||||
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
|
||||
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
|
||||
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
|
||||
|
||||
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
|
||||
|
||||
---
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
|
||||
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
|
||||
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
|
||||
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||
```
|
||||
|
||||
### 典型 5 物料并发加热工作流示例
|
||||
|
||||
```
|
||||
prepare_materials (count=5)
|
||||
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
|
||||
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
|
||||
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
|
||||
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
|
||||
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
|
||||
```
|
||||
|
||||
创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。
|
||||
76
.cursor/skills/virtual-workbench/action-index.md
Normal file
76
.cursor/skills/virtual-workbench/action-index.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Action Index — virtual_workbench
|
||||
|
||||
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||
|
||||
---
|
||||
|
||||
## 物料准备
|
||||
|
||||
### `auto-prepare_materials`
|
||||
|
||||
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
|
||||
- **可选参数**: `count`(物料数量,默认 5)
|
||||
|
||||
---
|
||||
|
||||
## 机械臂 & 加热台操作
|
||||
|
||||
### `auto-move_to_heating_station`
|
||||
|
||||
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
|
||||
- **核心参数**: `material_number`(物料编号,integer)
|
||||
|
||||
### `auto-start_heating`
|
||||
|
||||
启动指定加热台的加热程序(可并行,3 个加热台同时工作)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
|
||||
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||
|
||||
### `auto-move_to_output`
|
||||
|
||||
将加热完成的物料从加热台移动到输出位置 Cn
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
|
||||
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||
|
||||
---
|
||||
|
||||
## 物料转移
|
||||
|
||||
### `transfer`
|
||||
|
||||
异步转移物料到目标设备(通过 ROS 资源转移)
|
||||
|
||||
- **action_type**: `UniLabJsonCommandAsync`
|
||||
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
|
||||
- **核心参数**: `resource`, `target_device`, `mount_resource`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
|
||||
- `target_device` — **DeviceSlot**,目标设备路径字符串
|
||||
- `mount_resource` — **ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
|
||||
|
||||
---
|
||||
|
||||
## 人工确认
|
||||
|
||||
### `manual_confirm`
|
||||
|
||||
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,物料数组
|
||||
- `target_device` — **DeviceSlot**,目标设备路径
|
||||
- `mount_resource` — **ResourceSlot**,目标孔位数组
|
||||
- `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||
270
.cursor/skills/virtual-workbench/actions/manual_confirm.json
Normal file
270
.cursor/skills/virtual-workbench/actions/manual_confirm.json
Normal file
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"target_device": "target_device",
|
||||
"mount_resource": "mount_resource",
|
||||
"timeout_seconds": "timeout_seconds",
|
||||
"assignee_user_ids": "assignee_user_ids"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target_device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"mount_resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "mount_resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"assignee_user_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource",
|
||||
"target_device",
|
||||
"mount_resource",
|
||||
"timeout_seconds",
|
||||
"assignee_user_ids"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources",
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources",
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
24
.cursor/skills/virtual-workbench/actions/move_to_output.json
Normal file
24
.cursor/skills/virtual-workbench/actions/move_to_output.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"station_id": "station_id",
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"station_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"station_id",
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"count": "count"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"default": 5
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"goal_default": {
|
||||
"count": 5
|
||||
},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
24
.cursor/skills/virtual-workbench/actions/start_heating.json
Normal file
24
.cursor/skills/virtual-workbench/actions/start_heating.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"station_id": "station_id",
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"station_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"station_id",
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
255
.cursor/skills/virtual-workbench/actions/transfer.json
Normal file
255
.cursor/skills/virtual-workbench/actions/transfer.json
Normal file
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"type": "UniLabJsonCommandAsync",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"target_device": "target_device",
|
||||
"mount_resource": "mount_resource"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target_device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"mount_resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "mount_resource"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource",
|
||||
"target_device",
|
||||
"mount_resource"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources"
|
||||
}
|
||||
}
|
||||
2
.github/workflows/ci-check.yml
vendored
2
.github/workflows/ci-check.yml
vendored
@@ -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: |
|
||||
|
||||
77
.github/workflows/conda-pack-build.yml
vendored
77
.github/workflows/conda-pack-build.yml
vendored
@@ -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"
|
||||
|
||||
4
.github/workflows/deploy-docs.yml
vendored
4
.github/workflows/deploy-docs.yml
vendored
@@ -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: |
|
||||
|
||||
22
.github/workflows/multi-platform-build.yml
vendored
22
.github/workflows/multi-platform-build.yml
vendored
@@ -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..."
|
||||
|
||||
57
.github/workflows/unilabos-conda-build.yml
vendored
57
.github/workflows/unilabos-conda-build.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
@@ -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后查看日志
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -626,7 +626,7 @@ unilab
|
||||
|
||||
**云端图文件管理**:
|
||||
|
||||
1. 登录 https://uni-lab.bohrium.com
|
||||
1. 登录 https://leap-lab.bohrium.com
|
||||
2. 进入"设备配置"
|
||||
3. 创建或编辑配置
|
||||
4. 保存到云端
|
||||
|
||||
@@ -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` 参数
|
||||
- 配置文件中包含正确的认证信息
|
||||
|
||||
|
||||
576
plan/2026-05-20_add_two_node.md
Normal file
576
plan/2026-05-20_add_two_node.md
Normal file
@@ -0,0 +1,576 @@
|
||||
# Peptide Station 新增三个节点:等待订单完成 + 下料确认 + take-out 同步
|
||||
|
||||
> 日期: 2026-05-20
|
||||
> 目标文件: [peptide_station.py](../unilabos/devices/workstation/bioyond_studio/peptide_station/peptide_station.py)
|
||||
> 参考实现:
|
||||
> - [bioyond_cell_workstation.py](/Users/dp/python/yxz/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)(`wait_for_order_finish`、`get_material_info`)
|
||||
> - [bioyond_rpc.py L782-824](../unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py)(`take_out`)
|
||||
> - [workstation_architecture.md](../docs/developer_guide/examples/workstation_architecture.md)(HTTP 报送进入 workstation,运行态记录保存在 workstation 内存)
|
||||
> 状态: 仅需求草稿,不写代码
|
||||
|
||||
---
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
`BioyondPeptideStation` 当前实验流程在 `start_experiment`(manual_confirm 启动调度器)之后即结束,缺少:
|
||||
|
||||
1. **等待奔耀回报实验完成**:调度器跑完后,奔耀通过 LIMS 推送 `POST /report/order_finish` 回调;目前 peptide_station 没有把这条推送封装成 action 节点,下游无法在工作流图上等待结果,也拿不到 `usedMaterials` 等下游所需信息。
|
||||
2. **下料引导**:实验完成后操作员需要把样品/产物从仓位里取出,下料前需要看到每个物料对应的 **仓库 / 位置 / 物料名称 / 数量**;下料完成后还需要回写奔耀(调用 `take-out` 接口),让奔耀清空相应库位状态。
|
||||
|
||||
本轮新增三个 action 节点,串在 `start_experiment` 之后:
|
||||
|
||||
```text
|
||||
submit_experiment_dayN
|
||||
-> start_experiment(manual_confirm 上料)
|
||||
-> wait_for_order_finish (等回调 + 生成 unloadTable)
|
||||
-> confirm_unload_materials (manual_confirm 下料确认)
|
||||
-> take_out_materials (调用 take-out 同步奔耀)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、关键设计决策
|
||||
|
||||
### D1. 本轮只支持单订单,`order_ids` 只做占位
|
||||
|
||||
当前不实现多订单等待、乱序回调缓存、并发 wait 隔离。节点输入以 `order_id` 为主,`order_code` 可作为调试兜底。
|
||||
|
||||
`order_ids` 可在 handle/返回值中保留为占位字段,但实现只处理第一笔或直接忽略多订单列表。多订单、乱序回调、跨节点重跑复用缓存放到后续迭代。
|
||||
|
||||
### D2. `start_experiment` 需要显式透传订单字段
|
||||
|
||||
当前 `start_experiment` 只有输入 handles,缺少输出 handles;如果下游 wait 节点要接在 `start_experiment` 之后,必须让 `start_experiment` 透传:
|
||||
|
||||
- `order_id`
|
||||
- `order_ids`(占位)
|
||||
- `order_code`
|
||||
- `resultTable`
|
||||
|
||||
实现时给 `start_experiment` 增加对应 `ActionOutputHandle`,并在返回值里保留这些字段。`submit_experiment_dayN` 的 `start_experiment` 嵌套字典也应包含 `order_code`,便于工作流编辑器连线。
|
||||
|
||||
### D3. `unloadTable` 必须与 `resultTable` 字段一致
|
||||
|
||||
`unloadTable` 不新增 `posX/posY/posZ/unit` 列,直接复用现有 `RESULT_TABLE_COLUMNS` 的四列:
|
||||
|
||||
```python
|
||||
RESULT_TABLE_COLUMNS = [
|
||||
{"name": "设备", "key": "whName"},
|
||||
{"name": "位置", "key": "locationCode"},
|
||||
{"name": "物料名称", "key": "materialName"},
|
||||
{"name": "数量", "key": "quantity"},
|
||||
]
|
||||
```
|
||||
|
||||
`submit_experiment_dayN` 现有上料确认表 `resultTable` 形状如下;`unloadTable` 也必须保持同一 shape,只改 `tableName` 和行数据来源:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"whName": "自动化堆栈",
|
||||
"locationCode": "A1",
|
||||
"materialName": "96孔板",
|
||||
"quantity": "1"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
{"name": "设备", "key": "whName"},
|
||||
{"name": "位置", "key": "locationCode"},
|
||||
{"name": "物料名称", "key": "materialName"},
|
||||
{"name": "数量", "key": "quantity"}
|
||||
],
|
||||
"tableName": "resultTable"
|
||||
}
|
||||
```
|
||||
|
||||
`material-info` 官方 schema 中位置坐标字段是 `locations[].x/y/z`,不是 `posX/posY/posZ`。本轮下料表不展示坐标。
|
||||
|
||||
### D4. manual_confirm 只做人确认,take-out 放到普通 action
|
||||
|
||||
`confirm_unload_materials` 只负责展示下料表、等待操作员确认并透传数据;真正的 `take-out` 调用放到后续普通 action `take_out_materials`。
|
||||
|
||||
这样更接近 UniLab manual_confirm 的推荐模式:manual_confirm 是人机确认检查点,副作用由独立普通 action 执行。
|
||||
|
||||
### D5. 本轮不做 unload context 缓存
|
||||
|
||||
虽然 workstation architecture 文档支持在 workstation 内存保存 HTTP 报送记录,但本轮暂不实现 `unload_context_cache` 或 order report 缓存。
|
||||
|
||||
因此本轮限制如下:
|
||||
|
||||
- `wait_for_order_finish` 只等待本次进入节点之后到达的 `/report/order_finish`。
|
||||
- 如果 push 早于 wait 节点到达,本轮不自动补救。
|
||||
- 如果用户在 `confirm_unload_materials` approve 时忘记勾选,节点失败;当前架构不支持原地重新弹出同一个 manual_confirm,也不在本轮实现失败节点重跑。
|
||||
- 后续若要支持重跑复用,应在 `BioyondPeptideStation` 实例上新增 station runtime 的 `unload_context_cache`,按 `orderCode` 缓存 `unloadTable/material_ids/order_id` 等上下文。
|
||||
|
||||
---
|
||||
|
||||
## 三、节点 1:`wait_for_order_finish`(等推送 + 生成 unloadTable)
|
||||
|
||||
### 行为
|
||||
|
||||
1. 解析单订单目标:
|
||||
- 首选 `order_id`。
|
||||
- 如果没有 `order_code`,通过 `self.hardware_interface.order_report(order_id)` 取返回数据中的 `code` 作为 `orderCode`。
|
||||
- `order_ids` 仅占位,本轮不实现多订单循环。
|
||||
2. 设置 `self.last_order_code = order_code`、`self.last_order_report = None`,并 `self.order_finish_event.clear()`。
|
||||
3. 阻塞在 `self.order_finish_event.wait(timeout=timeout_seconds)` 等 LIMS 推送。
|
||||
4. peptide_station override `process_order_finish_report(report_request, used_materials)`:
|
||||
- 先调用 `super().process_order_finish_report(...)` 保留父类行为(状态发布、物料同步等)。
|
||||
- 当 `report_request.data.orderCode == self.last_order_code` 时,把 `report_request.data` 存入 `self.last_order_report`,并 `set()` event。
|
||||
- 非当前订单推送只记录日志,本轮不缓存。
|
||||
5. 解除阻塞后解析 `status`:
|
||||
- `"30"` -> `success`
|
||||
- `"-11"` -> `abnormal_stop`
|
||||
- `"-12"` -> `manual_stop`
|
||||
- 其它 -> `unknown_<status>`
|
||||
- 超时 -> `timeout`
|
||||
6. 对 `report.usedMaterials[].materialId` 调用 `self.hardware_interface.material_info(material_id)`,带本地函数级 `material_info_cache` 避免重复请求。
|
||||
7. 组装 `unloadTable`、`material_ids`、`preintake_ids`、`unload_summary` 并作为输出 handles 暴露。
|
||||
|
||||
### 入参(goal_default)
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `order_id` | `str` | 来自 `start_experiment` 透传输出,必填优先 |
|
||||
| `order_code` | `str` | 调试兜底;若已知 orderCode 可跳过 `order_report` 反查 |
|
||||
| `order_ids` | `List[str]` | 占位字段;本轮不实现多订单 |
|
||||
| `timeout_seconds` | `int` | 默认 `36000`(10h) |
|
||||
| `poll_mode` | `bool` | 默认 `False`;如需要可沿用 bioyond_cell 的轮询等待风格 |
|
||||
|
||||
### 输出 handles
|
||||
|
||||
| key | data_type | 说明 |
|
||||
|-----|-----------|------|
|
||||
| `order_finish_status` | `str` | `success` / `abnormal_stop` / `manual_stop` / `timeout` / `unknown_*` |
|
||||
| `order_finish_report` | `json` | 完整 `report_request.data` |
|
||||
| `used_materials` | `json` | JSON 化后的 `usedMaterials` 列表 |
|
||||
| `material_ids` | `json` | 从 `used_materials` 抽出的 `materialId` 列表,可为空 |
|
||||
| `preintake_ids` | `json` | 本轮默认 `[]`,保留扩展点 |
|
||||
| `unloadTable` | `table` | 下料表,字段与 `resultTable` 一致 |
|
||||
| `unload_summary` | `json` | `{ "order_code": ..., "total_items": N, "missing_material_info": [...] }` |
|
||||
| `order_id` | `str` | 透传给后续节点 |
|
||||
| `order_code` | `str` | 透传给后续节点 |
|
||||
| `order_ids` | `json` | 占位透传 |
|
||||
|
||||
### `unloadTable` 组装规则
|
||||
|
||||
返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"whName": "自动化堆栈",
|
||||
"locationCode": "A1",
|
||||
"materialName": "多肽产物",
|
||||
"quantity": "10 mg"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
{"name": "设备", "key": "whName"},
|
||||
{"name": "位置", "key": "locationCode"},
|
||||
{"name": "物料名称", "key": "materialName"},
|
||||
{"name": "数量", "key": "quantity"}
|
||||
],
|
||||
"tableName": "unloadTable"
|
||||
}
|
||||
```
|
||||
|
||||
每行字段:
|
||||
|
||||
| key | 数据来源 |
|
||||
|-----|----------|
|
||||
| `whName` | `material_info.locations` 中匹配 `usedMaterials.locationId` 的 location 的 `whName`;匹配不到取第一条 location 的 `whName`;失败为空串 |
|
||||
| `locationCode` | 匹配 location 的 `code`;匹配不到取第一条 location 的 `code`;再兜底 `usedMaterials.locationId` |
|
||||
| `materialName` | `material_info.name`;失败为空串 |
|
||||
| `quantity` | `usedMaterials.usedQuantity`,若 `material_info.unit` 存在则拼成字符串(如 `"10 mg"`) |
|
||||
|
||||
`material-info` 失败时不抛异常,对应行尽量保留 `locationCode` / `quantity`,`whName` 和 `materialName` 用空串,并把 `materialId` 放入 `unload_summary.missing_material_info`。
|
||||
|
||||
### 接口依赖
|
||||
|
||||
| 接口 | 调用方式 | 用途 |
|
||||
|------|----------|------|
|
||||
| `process_order_finish_report` 钩子 | 基类 HTTP 服务已注册 | 接 LIMS push |
|
||||
| `POST /api/lims/order/order-report` | `self.hardware_interface.order_report(order_id)` | 从 `order_id` 反查 `orderCode` |
|
||||
| `POST /api/lims/storage/material-info` | `self.hardware_interface.material_info(material_id)` | 查 `whName/locationCode/materialName/unit` |
|
||||
|
||||
#### `order_report(order_id)` API 形式
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "2026-05-20T10:50:00.123Z",
|
||||
"data": "<orderId UUID>"
|
||||
}
|
||||
```
|
||||
|
||||
响应中本节点只依赖 `data.code`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": null,
|
||||
"timestamp": 1779255000000,
|
||||
"data": {
|
||||
"id": "<orderId UUID>",
|
||||
"name": "实验260520-103000",
|
||||
"code": "EXP260520-103000",
|
||||
"status": 30,
|
||||
"statusName": "完成"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`BioyondV1RPC.order_report(order_id)` 已经返回响应中的 `data`,所以实现中应读取 `raw.get("code")`,不是 `raw["data"]["code"]`。
|
||||
|
||||
#### `material_info(material_id)` API 形式
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "2026-05-20T10:50:00.123Z",
|
||||
"data": "<materialId UUID>"
|
||||
}
|
||||
```
|
||||
|
||||
响应中本节点依赖:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<materialId UUID>",
|
||||
"name": "多肽产物",
|
||||
"unit": "mg",
|
||||
"locations": [
|
||||
{
|
||||
"id": "<locationId UUID>",
|
||||
"whid": "<warehouse UUID>",
|
||||
"whName": "自动化堆栈",
|
||||
"code": "A1",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"quantity": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`BioyondV1RPC.material_info(material_id)` 已经返回响应中的 `data`。字段映射:
|
||||
|
||||
| unloadTable key | 来源 |
|
||||
|-----------------|------|
|
||||
| `whName` | 匹配 `locationId` 的 `locations[].whName` |
|
||||
| `locationCode` | 匹配 `locationId` 的 `locations[].code` |
|
||||
| `materialName` | `name` |
|
||||
| `quantity` | `usedMaterials[].usedQuantity` + `unit` |
|
||||
|
||||
### 实现要点
|
||||
|
||||
- `BioyondPeptideStation.__init__` 末尾追加 `self.order_finish_event = threading.Event()`、`self.last_order_code = None`、`self.last_order_report = None`。
|
||||
- 新增 `process_order_finish_report` override,先 `super()`,再做单订单匹配。
|
||||
- `used_materials` 参数是 `MaterialUsage` dataclass 列表;输出前必须转成 JSON dict。
|
||||
- `unloadTable` 复用 `RESULT_TABLE_COLUMNS`,不新增 `UNLOAD_TABLE_COLUMNS`。
|
||||
|
||||
---
|
||||
|
||||
## 四、节点 2:`confirm_unload_materials`(人工下料确认)
|
||||
|
||||
### 行为
|
||||
|
||||
1. 接收节点 1 输出的 `order_id` / `order_code` / `material_ids` / `preintake_ids` / `unloadTable`。
|
||||
2. 进入 `NodeType.MANUAL_CONFIRM` 阻塞,操作员根据 `unloadTable` 物理下料。
|
||||
3. 操作员勾选 `materials_unloaded=True` 并 approve 后,节点函数体继续。
|
||||
4. 校验 `materials_unloaded == True`:
|
||||
- 为 True:返回确认结果,并透传 `order_id/material_ids/preintake_ids/unloadTable` 给节点 3。
|
||||
- 为 False:抛 `RuntimeError("下料未确认,拒绝继续 take-out")`。
|
||||
|
||||
### 入参(goal_default)
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `order_id` | `str` | 来自节点 1,必填 |
|
||||
| `order_code` | `str` | 来自节点 1,日志/排错用 |
|
||||
| `material_ids` | `List[str]` | 来自节点 1,可为空 |
|
||||
| `preintake_ids` | `List[str]` | 来自节点 1,默认 `[]` |
|
||||
| `unloadTable` | `table` | 来自节点 1,供人工确认展示 |
|
||||
| `materials_unloaded` | `bool` | manual_confirm 勾选字段,默认 `False` |
|
||||
| `timeout_seconds` | `int` | 默认 `3600` |
|
||||
| `assignee_user_ids` | `List[str]` | `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}` |
|
||||
|
||||
### 输出 handles
|
||||
|
||||
| key | data_type | 说明 |
|
||||
|-----|-----------|------|
|
||||
| `unload_confirmed` | `bool` | 是否已人工确认下料 |
|
||||
| `order_id` | `str` | 透传 |
|
||||
| `order_code` | `str` | 透传 |
|
||||
| `material_ids` | `json` | 透传 |
|
||||
| `preintake_ids` | `json` | 透传 |
|
||||
| `unloadTable` | `table` | 透传 |
|
||||
|
||||
### 实现要点
|
||||
|
||||
- 装饰器使用 `node_type=NodeType.MANUAL_CONFIRM`。
|
||||
- `always_free=True`、`placeholder_keys`、`feedback_interval=300` 与现有 `start_experiment` 保持一致。
|
||||
- 本节点不调用 `take_out`,只做确认与透传。
|
||||
- 忘记勾选后不会自动重新显示下料指引;本轮不实现缓存或失败节点原地重跑。
|
||||
|
||||
---
|
||||
|
||||
## 五、节点 3:`take_out_materials`(调用 take-out 同步奔耀)
|
||||
|
||||
### 行为
|
||||
|
||||
1. 接收节点 2 透传的 `order_id` / `material_ids` / `preintake_ids`。
|
||||
2. 校验 `order_id` 非空。
|
||||
3. 调用 `self.hardware_interface.take_out(order_id, preintake_ids=preintake_ids, material_ids=material_ids)`。
|
||||
4. 返回 `take_out_result`、`unloaded_count`、`success`。
|
||||
|
||||
### 入参
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `order_id` | `str` | 必填 |
|
||||
| `material_ids` | `List[str]` | 可为空;为空时由奔耀按 `orderId` 处理的能力以后现场确认 |
|
||||
| `preintake_ids` | `List[str]` | 可为空,默认 `[]` |
|
||||
| `order_code` | `str` | 日志/排错用 |
|
||||
|
||||
### 输出 handles
|
||||
|
||||
| key | data_type | 说明 |
|
||||
|-----|-----------|------|
|
||||
| `take_out_result` | `json` | `take-out` 原始响应 `{code, message, timestamp, data}` |
|
||||
| `unloaded_count` | `int` | `len(material_ids)` |
|
||||
| `success` | `bool` | `take_out_result.code == 1` 且 `data` 不为 False |
|
||||
|
||||
### 接口依赖
|
||||
|
||||
| 接口 | 调用方式 | 用途 |
|
||||
|------|----------|------|
|
||||
| `POST /api/lims/order/take-out` | `self.hardware_interface.take_out(order_id, preintake_ids, material_ids)` | 通知奔耀同步取出 |
|
||||
|
||||
请求体 schema(helper script 已核对):
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "2026-05-20T10:50:00.123Z",
|
||||
"data": {
|
||||
"orderId": "<UUID>",
|
||||
"preintakeIds": [],
|
||||
"materialIds": ["<UUID-1>", "<UUID-2>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应 schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": null,
|
||||
"timestamp": 1779255000000,
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
`BioyondV1RPC.take_out(...)` 返回完整响应包,因此 `take_out_materials` 应保留原始包到 `take_out_result`。
|
||||
|
||||
### 实现要点
|
||||
|
||||
- 本轮不修改 `sample_waste_removal`,保持 backward compatibility。
|
||||
- 新节点只调用现有完整能力的 `take_out(...)`。
|
||||
- `preintake_ids` / `material_ids` 都按可选列表处理,默认 `[]`。
|
||||
- `take-out` 返回 `code != 1` 时返回 `success=False` 并记录 warning;是否抛异常留作开放问题。
|
||||
|
||||
---
|
||||
|
||||
## 六、端到端工作流连线
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
submit["submit_experiment_dayN"] --> start["start_experiment<br/>manual_confirm: 上料"]
|
||||
start -->|order_id, order_code| wait["wait_for_order_finish<br/>等 order_finish + 生成 unloadTable"]
|
||||
wait -->|order_id, material_ids,<br/>preintake_ids, unloadTable| confirm["confirm_unload_materials<br/>manual_confirm: 操作员下料确认"]
|
||||
confirm -->|order_id, material_ids,<br/>preintake_ids| takeout["take_out_materials<br/>调用 take-out 同步奔耀"]
|
||||
bioyond["奔耀 LIMS"] -.HTTP POST /report/order_finish.-> wait
|
||||
wait -.material-info.-> bioyond
|
||||
takeout -.take-out.-> bioyond
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、影响面与兼容性
|
||||
|
||||
- **`peptide_station.py`**
|
||||
- 修改 `start_experiment`:增加 `order_id/order_code/order_ids/resultTable` 输出 handles,并在返回值透传。
|
||||
- 增加 `wait_for_order_finish`、`confirm_unload_materials`、`take_out_materials` 三个 action。
|
||||
- 增加 `process_order_finish_report` override。
|
||||
- 增加 `_build_unload_table(...)` 等私有辅助方法。
|
||||
- **`bioyond_rpc.py` 不动**
|
||||
- `take_out` 已有完整 schema 能力。
|
||||
- `sample_waste_removal` 本轮不改,保持兼容。
|
||||
- **基类 `station.py` 不动**
|
||||
- override 中保留 `super().process_order_finish_report(...)` 调用。
|
||||
- **HTTP 服务不动**
|
||||
- `WorkstationHTTPService` 已支持 `/report/order_finish`。
|
||||
- **本轮不做缓存**
|
||||
- 不新增 `unload_context_cache`。
|
||||
- 不支持 push 早于 wait 的自动补救。
|
||||
- 不支持失败 manual_confirm 原地重跑。
|
||||
- **测试**:补在现有路径 `unilabos/devices/workstation/bioyond_studio/peptide_station/tests/test_peptide_station_contracts.py`
|
||||
1. `start_experiment` 输出 handles/返回值透传 `order_id/order_code/order_ids/resultTable`。
|
||||
2. `process_order_finish_report` orderCode 匹配 / 不匹配时 event 是否触发。
|
||||
3. `wait_for_order_finish` 单订单成功、超时、状态映射、`used_materials` JSON 化。
|
||||
4. `_build_unload_table` 列顺序严格等于 `RESULT_TABLE_COLUMNS`,且无 `posX/posY/posZ/unit` 列。
|
||||
5. `material-info` 失败时不抛异常,`missing_material_info` 正确记录。
|
||||
6. `confirm_unload_materials` 未勾选时报错,勾选后透传下游字段且不调用 `take_out`。
|
||||
7. `take_out_materials` 调用 `hardware_interface.take_out(order_id, preintake_ids, material_ids)`,不调用 `sample_waste_removal`。
|
||||
|
||||
---
|
||||
|
||||
## 八、待人类确认的开放问题
|
||||
|
||||
1. **过滤产物 vs 全量**:`usedMaterials` 同时包含试剂、耗材、样品(`typeMode` 区分),下料表是否需要默认排除试剂/耗材?当前默认全量列出。
|
||||
2. **take-out 失败是否阻塞工作流**:本计划暂定返回 `success=False` 并 warning,不抛异常;如果希望奔耀仓位状态必须一致,可改为抛 `RuntimeError`。
|
||||
3. **后续缓存/重跑能力**:如果要支持 push 早到、忘勾选后重跑复用 `unloadTable`,后续应在 `BioyondPeptideStation` station runtime 上实现 `unload_context_cache`,但本轮不做。
|
||||
4. **多订单**:本轮只保留 `order_ids` 占位,不实现多订单等待、乱序回调或并发 wait。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:API schema 核对摘要
|
||||
|
||||
使用 `temp_benyao/scripts/api_helper.py --root temp_benyao/peptide` 核对:
|
||||
|
||||
### A.1 `POST /api/lims/storage/material-info`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "2026-05-20T10:50:00.123Z",
|
||||
"data": "<materialId UUID>"
|
||||
}
|
||||
```
|
||||
|
||||
响应关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<materialId UUID>",
|
||||
"typeName": "样品",
|
||||
"code": "MAT-001",
|
||||
"barCode": "BC-001",
|
||||
"name": "多肽产物",
|
||||
"quantity": 10,
|
||||
"lockQuantity": 0,
|
||||
"unit": "mg",
|
||||
"status": 1,
|
||||
"isUse": true,
|
||||
"locations": [
|
||||
{
|
||||
"id": "<locationId UUID>",
|
||||
"whid": "<warehouse UUID>",
|
||||
"whName": "自动化堆栈",
|
||||
"code": "A1",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"quantity": 10
|
||||
}
|
||||
],
|
||||
"detail": []
|
||||
}
|
||||
```
|
||||
|
||||
注意:schema 没有 `posX/posY/posZ`,本轮也不展示坐标。
|
||||
|
||||
### A.2 `POST /api/lims/order/take-out`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "2026-05-20T10:50:00.123Z",
|
||||
"data": {
|
||||
"orderId": "<orderId UUID>",
|
||||
"preintakeIds": [],
|
||||
"materialIds": ["<materialId UUID>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": null,
|
||||
"timestamp": 1779255000000,
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
源码中已有 `BioyondV1RPC.take_out(order_id, preintake_ids=None, material_ids=None)`,本轮复用它。
|
||||
|
||||
### A.3 `/report/order_finish`
|
||||
|
||||
`/report/order_finish` 不在 Peptide JSON OpenAPI specs 中;schema 依据:
|
||||
|
||||
- `unilabos/devices/workstation/workstation_http_service.py`
|
||||
- `temp_benyao/peptide/docs/reference/api_manual.md`
|
||||
|
||||
关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "token-from-lims",
|
||||
"request_time": "2026-05-20 10:50:00.123",
|
||||
"data": {
|
||||
"orderCode": "EXP260520-103000",
|
||||
"orderName": "实验260520-103000",
|
||||
"startTime": "2026-05-20 09:00:00",
|
||||
"endTime": "2026-05-20 10:50:00",
|
||||
"status": "30",
|
||||
"usedMaterials": [
|
||||
{
|
||||
"materialId": "<materialId UUID>",
|
||||
"locationId": "<locationId UUID>",
|
||||
"typeMode": "1",
|
||||
"usedQuantity": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`WorkstationHTTPService` 会把 `usedMaterials[]` 转成 `MaterialUsage` dataclass 列表传给 `process_order_finish_report(report_request, used_materials)`;peptide 输出 `used_materials` handle 前需要转回 JSON dict:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"materialId": "<materialId UUID>",
|
||||
"locationId": "<locationId UUID>",
|
||||
"typeMode": "1",
|
||||
"usedQuantity": 10
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:本轮不实现的内容
|
||||
|
||||
- 不做 station runtime 的 `unload_context_cache`。
|
||||
- 不做多订单。
|
||||
- 不做 push 早到后的补救。
|
||||
- 不做 failed manual_confirm 原地重跑。
|
||||
- 不改前端。
|
||||
- 不改 `sample_waste_removal`。
|
||||
461
plan/2026-05-21_01_peptide_reset_four_checkbox_plan.md
Normal file
461
plan/2026-05-21_01_peptide_reset_four_checkbox_plan.md
Normal file
@@ -0,0 +1,461 @@
|
||||
# Peptide Four-Checkbox Reset Plan
|
||||
|
||||
Date: 2026-05-21 16:30
|
||||
Status: Proposal only / not executed
|
||||
|
||||
## Scope
|
||||
|
||||
This plan replaces `2026-05-21_1556_peptide_reset_sirna_reference_plan.md` for Peptide reset work.
|
||||
|
||||
User direction captured here:
|
||||
|
||||
- `take_out` is unnecessary for Peptide reset.
|
||||
- Do not add a material-cache refresh checkbox.
|
||||
- Change reset to four checkbox-controlled operations:
|
||||
- 调度器复位
|
||||
- 订单状态复位
|
||||
- 库位复位
|
||||
- 仪器复位
|
||||
- The first three checkboxes default to checked.
|
||||
- The fourth checkbox, 仪器复位 / `reset_devices`, defaults to unchecked.
|
||||
- Replace the current public `reset` action with:
|
||||
- `reset_auto`: normal ILab action node. This is the renamed/replaced version of the current reset implementation.
|
||||
- `reset_manual`: manual-confirm action node with a physical cleanup confirmation message.
|
||||
|
||||
## Evidence Summary
|
||||
|
||||
Current Peptide source:
|
||||
|
||||
- Reset action code is currently in `unilabos/devices/workstation/bioyond_studio/peptide_station/peptide_station.py`.
|
||||
- Current Peptide reset selects `scheduler_reset`, `reset_order_status`, and `reset_location`, and passes ids to order/location resets.
|
||||
- `BioyondV1RPC.reset_devices()` already calls `/api/lims/device/reset-devices` with only `apiKey` and `requestTime`.
|
||||
- `BioyondV1RPC.scheduler_reset()` already calls `/api/lims/scheduler/reset` with only `apiKey` and `requestTime`.
|
||||
- `BioyondV1RPC.reset_order_status(order_id)` and `reset_location(location_id)` currently send `data`, but live probes showed that omitted `data` succeeds.
|
||||
|
||||
Live Peptide no-data reset probes using `temp_benyao/peptide/peptide_station_config.example.json`:
|
||||
|
||||
- `POST /api/lims/order/reset-order-status` with request keys `["apiKey", "requestTime"]` returned HTTP 200 and `code=1`.
|
||||
- `POST /api/lims/scheduler/reset` with request keys `["apiKey", "requestTime"]` returned HTTP 200 and `code=1`.
|
||||
- `POST /api/lims/storage/reset-location` with request keys `["apiKey", "requestTime"]` returned HTTP 200 and `code=1`.
|
||||
- `reset-devices` was not live-probed in this session, but the current RPC wrapper already sends no `data`.
|
||||
|
||||
Raw findings:
|
||||
|
||||
- `temp_benyao/peptide/_findings/2026-05-21_1613_reset_order_status_no_data_live.md`
|
||||
- `temp_benyao/peptide/_findings/2026-05-21_1615_remaining_resets_no_data_live.md`
|
||||
|
||||
## Proposed Public Actions
|
||||
|
||||
### `reset_auto`
|
||||
|
||||
Normal action node. This is the auto/no-manual-confirm path. It replaces the current public `reset` action; do not leave a second public `reset` action unless a later compatibility request explicitly asks for an alias.
|
||||
|
||||
Checkbox schema rule:
|
||||
|
||||
- Use plain `bool` annotations in the action signature.
|
||||
- Do not use `Annotated[bool, Field(...)]` for these checkbox params in this implementation plan.
|
||||
- The current AST registry schema path does not unwrap `Annotated[...]`; plain `bool` is required so generated JSON Schema marks the fields as boolean and the renderer can show checkboxes.
|
||||
- Put human-facing labels/descriptions in the method docstring or action description. If field-level `Field(description=...)` metadata is required later, add registry `Annotated` support and a schema test as a separate change.
|
||||
|
||||
Decorator shape:
|
||||
|
||||
```python
|
||||
@action(
|
||||
always_free=True,
|
||||
goal_default={
|
||||
"reset_scheduler": True,
|
||||
"reset_order_status": True,
|
||||
"reset_location": True,
|
||||
"reset_devices": False,
|
||||
},
|
||||
description="自动复位调度器/订单状态/库位,可选仪器复位",
|
||||
)
|
||||
def reset_auto(
|
||||
self,
|
||||
reset_scheduler: bool = True,
|
||||
reset_order_status: bool = True,
|
||||
reset_location: bool = True,
|
||||
reset_devices: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""自动复位调度器/订单状态/库位,可选仪器复位。
|
||||
|
||||
Args:
|
||||
reset_scheduler[调度器复位]: 调用 /api/lims/scheduler/reset,默认勾选。
|
||||
reset_order_status[订单状态复位]: 调用 /api/lims/order/reset-order-status,默认勾选。
|
||||
reset_location[库位复位]: 调用 /api/lims/storage/reset-location,默认勾选。
|
||||
reset_devices[仪器复位]: 调用 /api/lims/device/reset-devices,默认不勾选。
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Use real plain-`bool` parameters, not hidden `**kwargs` and not `Annotated`, so the action renderer can expose four checkboxes.
|
||||
- Rename/replace the existing `reset` action as `reset_auto`; the implementation should not keep the old id-shaped `reset` action as another public path by default.
|
||||
- Keep the three routine reset defaults checked.
|
||||
- Keep `reset_devices` unchecked because it can be broader and more disruptive.
|
||||
- Do not require or resolve order ids or location ids.
|
||||
- Do not call `take_out`.
|
||||
- Do not call `refresh_material_cache`.
|
||||
|
||||
### `reset_manual`
|
||||
|
||||
Manual-confirm node. It should show the operator a physical cleanup warning, then execute the same reset helper as `reset_auto` after the operator confirms.
|
||||
|
||||
Actual manual-confirm decorator pattern in this repo:
|
||||
|
||||
- Use `@action(node_type=NodeType.MANUAL_CONFIRM)`.
|
||||
- Set `always_free=True`.
|
||||
- Add `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}`.
|
||||
- Include `timeout_seconds: int` and `assignee_user_ids: list[str]`.
|
||||
- Add `goal_default` for `timeout_seconds` and `assignee_user_ids`.
|
||||
- Manual-confirm actions are normally side-effect-light, but existing Peptide `start_experiment` is already a `MANUAL_CONFIRM` action that performs scheduler start after the operator gate, so a reset-after-confirm pattern is compatible with current Peptide style.
|
||||
|
||||
Proposed confirmation text:
|
||||
|
||||
```text
|
||||
请确认G3、CEM、Tecan、撕膜机、封膜机、打标机、旋转堆栈上下料位、3个转台等位置的物料已清理完毕;
|
||||
请开门检查冰箱、IDOT、酶标仪、离心机、LCMS内部没有遗留物料。
|
||||
```
|
||||
|
||||
Decorator/function shape:
|
||||
|
||||
```python
|
||||
RESET_MANUAL_CONFIRM_MESSAGE = (
|
||||
"请确认G3、CEM、Tecan、撕膜机、封膜机、打标机、旋转堆栈上下料位、3个转台等位置的物料已清理完毕;\n"
|
||||
"请开门检查冰箱、IDOT、酶标仪、离心机、LCMS内部没有遗留物料。"
|
||||
)
|
||||
|
||||
@action(
|
||||
always_free=True,
|
||||
node_type=NodeType.MANUAL_CONFIRM,
|
||||
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
|
||||
goal_default={
|
||||
"reset_scheduler": True,
|
||||
"reset_order_status": True,
|
||||
"reset_location": True,
|
||||
"reset_devices": False,
|
||||
"physical_cleanup_confirmed": False,
|
||||
"timeout_seconds": 3600,
|
||||
"assignee_user_ids": [],
|
||||
},
|
||||
feedback_interval=300,
|
||||
description=RESET_MANUAL_CONFIRM_MESSAGE,
|
||||
)
|
||||
def reset_manual(
|
||||
self,
|
||||
reset_scheduler: bool = True,
|
||||
reset_order_status: bool = True,
|
||||
reset_location: bool = True,
|
||||
reset_devices: bool = False,
|
||||
physical_cleanup_confirmed: bool = False,
|
||||
timeout_seconds: int = 3600,
|
||||
assignee_user_ids: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""人工确认物理清理后执行复位。
|
||||
|
||||
Args:
|
||||
reset_scheduler[调度器复位]: 调用 /api/lims/scheduler/reset,默认勾选。
|
||||
reset_order_status[订单状态复位]: 调用 /api/lims/order/reset-order-status,默认勾选。
|
||||
reset_location[库位复位]: 调用 /api/lims/storage/reset-location,默认勾选。
|
||||
reset_devices[仪器复位]: 调用 /api/lims/device/reset-devices,默认不勾选。
|
||||
physical_cleanup_confirmed[物理清理确认]: 确认清理提示中的物料检查已经完成,默认不勾选。
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
Execution rule:
|
||||
|
||||
- If `physical_cleanup_confirmed` is false, return a blocked result and do not call any reset API.
|
||||
- If it is true, call the same internal helper as `reset_auto`.
|
||||
- Return `confirmation_message` in the result payload so call logs preserve the exact operator instruction text.
|
||||
|
||||
Renderer caveat:
|
||||
|
||||
- `description` should carry the warning in generated action metadata.
|
||||
- `physical_cleanup_confirmed` must remain a plain `bool` so it renders as a checkbox.
|
||||
- The cleanup warning should be carried by the action `description` and the docstring param description. Do not rely on `Field(description=...)` unless registry `Annotated` support has been implemented and tested.
|
||||
- If the current frontend does not show action descriptions or docstring field descriptions reliably, add a read-only string parameter such as `confirmation_message: str = RESET_MANUAL_CONFIRM_MESSAGE` with `goal_default`, or use a handle-based display only after renderer behavior is verified.
|
||||
|
||||
## Shared Internal Helper
|
||||
|
||||
Both public actions should delegate to one helper, for example:
|
||||
|
||||
```python
|
||||
def _execute_reset_operations(
|
||||
self,
|
||||
*,
|
||||
reset_scheduler: bool,
|
||||
reset_order_status: bool,
|
||||
reset_location: bool,
|
||||
reset_devices: bool,
|
||||
) -> Dict[str, Any]:
|
||||
...
|
||||
```
|
||||
|
||||
Call order:
|
||||
|
||||
1. `scheduler_reset`
|
||||
2. `reset_order_status`
|
||||
3. `reset_location`
|
||||
4. `reset_devices`
|
||||
|
||||
Result shape:
|
||||
|
||||
```python
|
||||
{
|
||||
"selected_operations": [
|
||||
{"key": "reset_scheduler", "label": "调度器复位", "selected": True},
|
||||
{"key": "reset_order_status", "label": "订单状态复位", "selected": True},
|
||||
{"key": "reset_location", "label": "库位复位", "selected": True},
|
||||
{"key": "reset_devices", "label": "仪器复位", "selected": False},
|
||||
],
|
||||
"executed_calls": [
|
||||
{"operation": "scheduler_reset", "endpoint": "/api/lims/scheduler/reset", "result": {"code": 1}},
|
||||
],
|
||||
"skipped_operations": [
|
||||
{"operation": "reset_devices", "reason": "checkbox_disabled"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
```
|
||||
|
||||
Failure handling:
|
||||
|
||||
- Execute selected operations sequentially and record each result.
|
||||
- If an operation returns non-`1` code, add a warning and continue unless the caller later requests fail-fast.
|
||||
- If an RPC method raises, catch it, record an error entry, and continue to the next selected operation unless fail-fast is introduced.
|
||||
|
||||
## RPC Wrapper Adjustment
|
||||
|
||||
Adjust the two id-shaped wrappers to no-data calls:
|
||||
|
||||
- `BioyondV1RPC.reset_order_status()` should no longer require `order_id`.
|
||||
- `BioyondV1RPC.reset_location()` should no longer require `location_id`.
|
||||
|
||||
Current no-data wrappers already exist:
|
||||
|
||||
- `scheduler_reset()`
|
||||
- `reset_devices()`
|
||||
|
||||
Suggested RPC signatures:
|
||||
|
||||
```python
|
||||
def scheduler_reset(self) -> int: ...
|
||||
def reset_order_status(self) -> int: ...
|
||||
def reset_location(self) -> int: ...
|
||||
def reset_devices(self) -> int: ...
|
||||
```
|
||||
|
||||
Compatibility option:
|
||||
|
||||
```python
|
||||
def reset_order_status(self, order_id: Optional[str] = None) -> int:
|
||||
del order_id
|
||||
...
|
||||
|
||||
def reset_location(self, location_id: Optional[str] = None) -> int:
|
||||
del location_id
|
||||
...
|
||||
```
|
||||
|
||||
This keeps older code from crashing while making the actual wire request no-data.
|
||||
|
||||
## Adjusted Runtime API Schemas
|
||||
|
||||
These are the schemas Peptide reset code should target at runtime after the live no-data probes. They intentionally omit `data`, even though OpenAPI models nullable `data` for these endpoints.
|
||||
|
||||
All four requests use:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "string",
|
||||
"requestTime": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
No `data` field should be sent by default.
|
||||
|
||||
All four responses use:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "",
|
||||
"timestamp": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 调度器复位
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/lims/scheduler/reset
|
||||
```
|
||||
|
||||
Adjusted request:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "2026-05-21T08:15:16.494Z"
|
||||
}
|
||||
```
|
||||
|
||||
Live response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "",
|
||||
"timestamp": 1779351316072
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenAPI says `data` is nullable int32.
|
||||
- Live Peptide accepted omitted `data`.
|
||||
|
||||
### 订单状态复位
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/lims/order/reset-order-status
|
||||
```
|
||||
|
||||
Adjusted request:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "2026-05-21T08:13:34.750Z"
|
||||
}
|
||||
```
|
||||
|
||||
Live response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "",
|
||||
"timestamp": 1779351214422
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenAPI says `data` is nullable string.
|
||||
- Live Peptide accepted omitted `data`.
|
||||
- Do not model this as order-id scoped unless Bioyond confirms backend behavior.
|
||||
|
||||
### 库位复位
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/lims/storage/reset-location
|
||||
```
|
||||
|
||||
Adjusted request:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "2026-05-21T08:15:18.924Z"
|
||||
}
|
||||
```
|
||||
|
||||
Live response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "",
|
||||
"timestamp": 1779351318565
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenAPI says `data` is nullable string.
|
||||
- Live Peptide accepted omitted `data`.
|
||||
- Do not model this as location-id scoped unless Bioyond confirms backend behavior.
|
||||
|
||||
### 仪器复位
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/lims/device/reset-devices
|
||||
```
|
||||
|
||||
Adjusted request:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "B10B5995",
|
||||
"requestTime": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
Expected response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "",
|
||||
"timestamp": 0
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenAPI says `data` is nullable string.
|
||||
- Current `BioyondV1RPC.reset_devices()` already sends no `data`.
|
||||
- This endpoint was not live-probed in the no-data reset session.
|
||||
- Keep checkbox default unchecked.
|
||||
|
||||
## Tests To Add Before Implementation
|
||||
|
||||
1. `reset_auto` is not `NodeType.MANUAL_CONFIRM`.
|
||||
2. `reset_manual` has `node_type=NodeType.MANUAL_CONFIRM`.
|
||||
3. `reset_manual` metadata includes:
|
||||
- `always_free=True`
|
||||
- `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}`
|
||||
- `timeout_seconds=3600`
|
||||
- `assignee_user_ids=[]`
|
||||
- `physical_cleanup_confirmed=False`
|
||||
4. Both reset actions expose four real boolean params:
|
||||
- `reset_scheduler`
|
||||
- `reset_order_status`
|
||||
- `reset_location`
|
||||
- `reset_devices`
|
||||
5. The generated registry schema marks those reset params as JSON Schema `type: boolean`, not `object` or `string`, so the frontend can render checkboxes.
|
||||
6. `reset_auto` replaces the current public `reset` action. Unless a later compatibility request adds an alias, no old id-shaped public `reset` action remains.
|
||||
7. Goal defaults are:
|
||||
- first three reset checkboxes `True`
|
||||
- `reset_devices=False`
|
||||
8. `reset_manual(..., physical_cleanup_confirmed=False)` does not call any RPC reset method.
|
||||
9. `reset_auto()` with defaults calls:
|
||||
- `scheduler_reset()`
|
||||
- `reset_order_status()`
|
||||
- `reset_location()`
|
||||
- not `reset_devices()`
|
||||
10. `reset_auto(reset_devices=True)` also calls `reset_devices()`.
|
||||
11. `reset_order_status()` and `reset_location()` RPC wrappers send no `data` key.
|
||||
12. No reset path calls `take_out`.
|
||||
13. No reset path calls `refresh_material_cache`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not implement `take_out` in reset.
|
||||
- Do not refresh `material_cache` from reset.
|
||||
- Do not resolve order ids or location ids for reset.
|
||||
- Do not add Project/cache/browser cleanup routes.
|
||||
- Do not make `reset_devices` default-on.
|
||||
- Do not execute this plan during planning.
|
||||
@@ -1,5 +1,5 @@
|
||||
channel_sources:
|
||||
- robostack,robostack-staging,conda-forge,defaults
|
||||
- robostack,robostack-staging,conda-forge
|
||||
|
||||
gazebo:
|
||||
- '11'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.19"
|
||||
version: "0.11.1"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
2
setup.py
2
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'],
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.19"
|
||||
__version__ = "0.11.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,25 +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)
|
||||
# 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": [x for xs in resources.dump() for x in xs], "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": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
data=body_bytes,
|
||||
headers=http_headers,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
@@ -111,6 +117,7 @@ class HTTPClient:
|
||||
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
||||
else:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
logger.trace(f"添加物料失败: {nodes_info}")
|
||||
for u, n in old_uuids.items():
|
||||
if u in uuid_mapping:
|
||||
n.res_content.uuid = uuid_mapping[u]
|
||||
@@ -131,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}"},
|
||||
@@ -145,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}")
|
||||
@@ -162,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}"},
|
||||
@@ -196,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}"},
|
||||
@@ -237,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}"},
|
||||
@@ -274,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}"},
|
||||
@@ -314,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,
|
||||
@@ -348,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),
|
||||
@@ -409,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}"},
|
||||
|
||||
@@ -1113,7 +1113,7 @@ class MessageProcessor:
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"free": free,
|
||||
"need_more": need_more,
|
||||
"need_more": need_more + 1,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1253,7 +1253,7 @@ class QueueProcessor:
|
||||
"task_id": job_info.task_id,
|
||||
"job_id": job_info.job_id,
|
||||
"free": False,
|
||||
"need_more": 10,
|
||||
"need_more": 10 + 1,
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
@@ -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,
|
||||
@@ -1286,7 +1292,7 @@ class QueueProcessor:
|
||||
"task_id": job_info.task_id,
|
||||
"job_id": job_info.job_id,
|
||||
"free": False,
|
||||
"need_more": 10,
|
||||
"need_more": 10 + 1,
|
||||
},
|
||||
}
|
||||
success = self.message_processor.send_message(message)
|
||||
@@ -1369,6 +1375,10 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
||||
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
||||
|
||||
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
|
||||
self._job_running_last_sent: Dict[str, tuple] = {}
|
||||
self._job_running_debounce_interval: float = 10.0 # 秒
|
||||
|
||||
# 设置相互引用
|
||||
self.message_processor.set_queue_processor(self.queue_processor)
|
||||
self.message_processor.set_websocket_client(self)
|
||||
@@ -1468,22 +1478,32 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
||||
return
|
||||
|
||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||
|
||||
# 拦截最终结果状态,与原版本逻辑一致
|
||||
if status in ["success", "failed"]:
|
||||
self._job_running_last_sent.pop(item.job_id, None)
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node:
|
||||
# 从HostNode的device_action_status中移除job_id
|
||||
try:
|
||||
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||
|
||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
|
||||
# 通知队列处理器job完成(包括timeout的job)
|
||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||
|
||||
# 发送job状态消息
|
||||
# running状态按job_id做debounce,内容变化时仍然上报
|
||||
if status == "running":
|
||||
now = time.time()
|
||||
cached = self._job_running_last_sent.get(item.job_id)
|
||||
if cached is not None:
|
||||
last_ts, last_data = cached
|
||||
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
|
||||
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
|
||||
return
|
||||
self._job_running_last_sent[item.job_id] = (now, feedback_data)
|
||||
|
||||
message = {
|
||||
"action": "job_status",
|
||||
"data": {
|
||||
@@ -1499,7 +1519,6 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
|
||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||
|
||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||
|
||||
@@ -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配置
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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)"
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -779,6 +779,49 @@ class BioyondV1RPC(BaseRequest):
|
||||
|
||||
return response.get("data", {})
|
||||
|
||||
def take_out(
|
||||
self,
|
||||
order_id: str,
|
||||
preintake_ids: list[str] | None = None,
|
||||
material_ids: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""取出订单关联通量/物料
|
||||
|
||||
参数:
|
||||
order_id: 订单ID
|
||||
preintake_ids: 通量ID列表,可为空
|
||||
material_ids: 物料ID列表,可为空
|
||||
|
||||
返回值:
|
||||
dict: 服务端响应包,失败返回空字典
|
||||
"""
|
||||
if not order_id:
|
||||
self._logger.error("取出订单关联通量/物料错误: 缺少订单ID")
|
||||
return {}
|
||||
|
||||
params = {
|
||||
"orderId": order_id,
|
||||
"preintakeIds": list(preintake_ids or []),
|
||||
"materialIds": list(material_ids or []),
|
||||
}
|
||||
|
||||
response = self.post(
|
||||
url=f'{self.host}/api/lims/order/take-out',
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": params,
|
||||
})
|
||||
|
||||
if not response:
|
||||
return {}
|
||||
|
||||
if response['code'] != 1:
|
||||
self._logger.error(f"取出订单关联通量/物料错误: {response.get('message', '')}")
|
||||
return response
|
||||
|
||||
return response
|
||||
|
||||
def cancel_order(self, json_str: str) -> bool:
|
||||
"""取消指定任务
|
||||
|
||||
|
||||
459
unilabos/devices/workstation/bioyond_studio/debug_call_log.py
Normal file
459
unilabos/devices/workstation/bioyond_studio/debug_call_log.py
Normal file
@@ -0,0 +1,459 @@
|
||||
"""Per-action raw call/response log for Bioyond stations.
|
||||
|
||||
When a debug session is active, ``wrap_rpc_http`` replaces a ``BioyondV1RPC``
|
||||
instance's ``post`` / ``get`` methods with closures that perform the HTTP
|
||||
transport themselves, capture the request/response details, and append a record
|
||||
to the active session before returning exactly what ``BaseRequest`` would have
|
||||
returned. Outside of an active session the wrapped method delegates to the
|
||||
original (unwrapped) implementation, leaving non-debug behavior intact.
|
||||
|
||||
The session writes a Markdown file under ``out_dir`` mirroring the format of
|
||||
``bioyond_debug_records/2026-04-30_160316_day3_samplefile_only_raw_calls.md``
|
||||
minus the "Raw Payload Argument" section.
|
||||
|
||||
This module has no dependency on ``BioyondV1RPC`` itself; the only contract is
|
||||
that the wrapped instance descends from ``BaseRequest`` (i.e. has a logger
|
||||
returned by ``self.get_logger()``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
__all__ = [
|
||||
"CallRecord",
|
||||
"CallLogContext",
|
||||
"session",
|
||||
"wrap_rpc_http",
|
||||
"active_session",
|
||||
]
|
||||
|
||||
|
||||
_DEFAULT_TIMEOUT_GET = 30
|
||||
_DEFAULT_TIMEOUT_POST = 120
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallRecord:
|
||||
"""One captured HTTP call inside a debug session."""
|
||||
|
||||
index: int
|
||||
method: str
|
||||
url: str
|
||||
path: str
|
||||
source: str
|
||||
transport: str
|
||||
http_status: Optional[int]
|
||||
request_body: Any
|
||||
response_body: Any
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallLogContext:
|
||||
"""State for a single ``session()`` block.
|
||||
|
||||
A session lazily creates its file on the first appended record. Actions
|
||||
that abort before any RPC produce no file.
|
||||
"""
|
||||
|
||||
action: str
|
||||
out_dir: Path
|
||||
started_at: datetime
|
||||
calls: List[CallRecord] = field(default_factory=list)
|
||||
file_path: Optional[Path] = None
|
||||
|
||||
def append(self, record: CallRecord) -> None:
|
||||
record.index = len(self.calls) + 1
|
||||
self.calls.append(record)
|
||||
self._write_file()
|
||||
|
||||
# -- file I/O -------------------------------------------------------------
|
||||
|
||||
def _resolve_file_path(self) -> Path:
|
||||
if self.file_path is not None:
|
||||
return self.file_path
|
||||
timestamp = self.started_at.strftime("%Y-%m-%d_%H%M%S")
|
||||
slug = _slugify_action(self.action)
|
||||
candidate = self.out_dir / f"{timestamp}_{slug}_raw_calls.md"
|
||||
suffix = 2
|
||||
while candidate.exists():
|
||||
candidate = (
|
||||
self.out_dir
|
||||
/ f"{timestamp}_{slug}_raw_calls_{suffix:02d}.md"
|
||||
)
|
||||
suffix += 1
|
||||
self.file_path = candidate
|
||||
return self.file_path
|
||||
|
||||
def _write_file(self) -> None:
|
||||
path = self._resolve_file_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(_render_markdown(self), encoding="utf-8")
|
||||
|
||||
|
||||
_active_session: contextvars.ContextVar[Optional[CallLogContext]] = (
|
||||
contextvars.ContextVar("_active_session", default=None)
|
||||
)
|
||||
|
||||
|
||||
def active_session() -> Optional[CallLogContext]:
|
||||
"""Return the currently active :class:`CallLogContext`, if any."""
|
||||
return _active_session.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session(action: str, out_dir: Path) -> Iterator[CallLogContext]:
|
||||
"""Open a per-action debug session.
|
||||
|
||||
On entry, sets the module-level ``_active_session`` ContextVar so any
|
||||
``wrap_rpc_http``'d clients on the same thread/task record their calls.
|
||||
On exit, the previous active session (if any) is restored.
|
||||
"""
|
||||
ctx = CallLogContext(
|
||||
action=str(action),
|
||||
out_dir=Path(out_dir),
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
token = _active_session.set(ctx)
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
_active_session.reset(token)
|
||||
|
||||
|
||||
def wrap_rpc_http(rpc: Any) -> None:
|
||||
"""Idempotently wrap ``rpc.post`` / ``rpc.get``.
|
||||
|
||||
When a session is active (``_active_session.get() is not None``), the
|
||||
wrapped methods perform the HTTP call themselves with ``requests`` and
|
||||
record the call before returning the same value ``BaseRequest`` would have
|
||||
returned. When no session is active, the wrapped methods delegate to the
|
||||
original implementation, preserving stock ``BaseRequest`` behavior.
|
||||
|
||||
Calling this twice on the same instance is a no-op. The wrapper does not
|
||||
alter ``rpc.form_post`` (no Sirna action calls it as of plan 3).
|
||||
"""
|
||||
if rpc is None:
|
||||
return
|
||||
if getattr(rpc, "_debug_call_log_wrapped", False):
|
||||
return
|
||||
|
||||
rpc._orig_post = rpc.post
|
||||
rpc._orig_get = rpc.get
|
||||
|
||||
def _wrapped_post(
|
||||
url: str,
|
||||
params: Any = None,
|
||||
files: Any = None,
|
||||
headers: Optional[dict] = None,
|
||||
) -> Any:
|
||||
ctx = _active_session.get()
|
||||
if ctx is None:
|
||||
kwargs = {}
|
||||
if params is not None:
|
||||
kwargs["params"] = params
|
||||
if files is not None:
|
||||
kwargs["files"] = files
|
||||
if headers is not None:
|
||||
kwargs["headers"] = headers
|
||||
return rpc._orig_post(url, **kwargs)
|
||||
effective_params = params if params is not None else {}
|
||||
effective_headers = (
|
||||
headers
|
||||
if headers is not None
|
||||
else {"Content-Type": "application/json"}
|
||||
)
|
||||
source = _detect_source(rpc)
|
||||
request_body = _redact(effective_params)
|
||||
record = CallRecord(
|
||||
index=0,
|
||||
method="POST",
|
||||
url=str(url),
|
||||
path=_url_path(url),
|
||||
source=source,
|
||||
transport=_pick_transport(effective_params),
|
||||
http_status=None,
|
||||
request_body=request_body,
|
||||
response_body=None,
|
||||
error=None,
|
||||
)
|
||||
return_value: Any = None
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
data=json.dumps(effective_params) if effective_params else None,
|
||||
headers=effective_headers,
|
||||
timeout=_DEFAULT_TIMEOUT_POST,
|
||||
files=files,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - delegated to logger
|
||||
record.error = f"transport error: {exc}"
|
||||
try:
|
||||
rpc.get_logger().error(f"Request ERROR: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
ctx.append(record)
|
||||
return None
|
||||
|
||||
record.http_status = response.status_code
|
||||
record.response_body, parse_error = _decode_response_body(response)
|
||||
try:
|
||||
rpc.get_logger().debug(
|
||||
f"Request >>> : {response.request.body} "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if response.status_code == 200:
|
||||
if parse_error is not None:
|
||||
record.error = f"json parse error: {parse_error}"
|
||||
return_value = None
|
||||
else:
|
||||
return_value = record.response_body
|
||||
else:
|
||||
record.error = f"HTTP {response.status_code}: {response.text}"
|
||||
try:
|
||||
rpc.get_logger().error(
|
||||
f"Request ERROR: ('Request ERROR:', {response.text!r})"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return_value = None
|
||||
|
||||
ctx.append(record)
|
||||
return return_value
|
||||
|
||||
def _wrapped_get(
|
||||
url: str,
|
||||
params: Any = None,
|
||||
headers: Optional[dict] = None,
|
||||
) -> Any:
|
||||
ctx = _active_session.get()
|
||||
if ctx is None:
|
||||
kwargs = {}
|
||||
if params is not None:
|
||||
kwargs["params"] = params
|
||||
if headers is not None:
|
||||
kwargs["headers"] = headers
|
||||
return rpc._orig_get(url, **kwargs)
|
||||
effective_params = params if params is not None else {}
|
||||
effective_headers = (
|
||||
headers
|
||||
if headers is not None
|
||||
else {"Content-Type": "application/json"}
|
||||
)
|
||||
source = _detect_source(rpc)
|
||||
request_body = _redact(effective_params)
|
||||
record = CallRecord(
|
||||
index=0,
|
||||
method="GET",
|
||||
url=str(url),
|
||||
path=_url_path(url),
|
||||
source=source,
|
||||
transport="params",
|
||||
http_status=None,
|
||||
request_body=request_body,
|
||||
response_body=None,
|
||||
error=None,
|
||||
)
|
||||
return_value: Any = None
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
params=effective_params,
|
||||
headers=effective_headers,
|
||||
timeout=_DEFAULT_TIMEOUT_GET,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - delegated to logger
|
||||
record.error = f"transport error: {exc}"
|
||||
try:
|
||||
rpc.get_logger().error(f"Request ERROR: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
ctx.append(record)
|
||||
return None
|
||||
|
||||
record.http_status = response.status_code
|
||||
record.response_body, parse_error = _decode_response_body(response)
|
||||
try:
|
||||
rpc.get_logger().debug(
|
||||
f"Request >>> : {effective_params} "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if response.status_code == 200:
|
||||
if parse_error is not None:
|
||||
record.error = f"json parse error: {parse_error}"
|
||||
return_value = None
|
||||
else:
|
||||
return_value = record.response_body
|
||||
|
||||
ctx.append(record)
|
||||
return return_value
|
||||
|
||||
rpc.post = _wrapped_post
|
||||
rpc.get = _wrapped_get
|
||||
rpc._debug_call_log_wrapped = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_URL_PATH_RE = re.compile(r"https?://[^/]+(/.*)?$")
|
||||
_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def _slugify_action(action: str) -> str:
|
||||
slug = _SLUG_RE.sub("_", str(action)).strip("_")
|
||||
return slug or "action"
|
||||
|
||||
|
||||
def _url_path(url: Any) -> str:
|
||||
text = str(url or "")
|
||||
match = _URL_PATH_RE.match(text)
|
||||
if match and match.group(1):
|
||||
return match.group(1)
|
||||
if text.startswith("/"):
|
||||
return text
|
||||
return text
|
||||
|
||||
|
||||
def _pick_transport(params: Any) -> str:
|
||||
if isinstance(params, dict) and "data" in params:
|
||||
return "data"
|
||||
return "params"
|
||||
|
||||
|
||||
def _detect_source(rpc: Any) -> str:
|
||||
"""Walk the call stack to find the outermost frame whose ``self`` is rpc."""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
except Exception:
|
||||
return ""
|
||||
candidate = ""
|
||||
try:
|
||||
for frame_info in stack:
|
||||
frame = frame_info.frame
|
||||
if frame.f_locals.get("self", None) is rpc:
|
||||
candidate = frame_info.function
|
||||
return candidate
|
||||
finally:
|
||||
del stack
|
||||
|
||||
|
||||
def _redact(params: Any) -> Any:
|
||||
"""Return a copy of ``params`` with ``apiKey`` redacted."""
|
||||
try:
|
||||
cloned = copy.deepcopy(params)
|
||||
except Exception:
|
||||
return params
|
||||
_redact_in_place(cloned)
|
||||
return cloned
|
||||
|
||||
|
||||
def _redact_in_place(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key in list(value.keys()):
|
||||
if isinstance(key, str) and key.lower() == "apikey":
|
||||
value[key] = "<redacted>"
|
||||
else:
|
||||
_redact_in_place(value[key])
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_redact_in_place(item)
|
||||
|
||||
|
||||
def _decode_response_body(response: Any) -> tuple[Any, Optional[str]]:
|
||||
"""Best-effort response decoding used for both record + return value."""
|
||||
text = getattr(response, "text", "")
|
||||
try:
|
||||
return response.json(), None
|
||||
except Exception as exc:
|
||||
if text:
|
||||
return {"raw_text": text}, str(exc)
|
||||
return None, str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_markdown(ctx: CallLogContext) -> str:
|
||||
title = f"# {ctx.action} Raw Call/Response Log"
|
||||
parts: List[str] = [title, ""]
|
||||
parts.append("## LIMS Calls")
|
||||
parts.append("")
|
||||
parts.append("| # | Method | Path | Source | HTTP |")
|
||||
parts.append("|---|---|---|---|---|")
|
||||
for record in ctx.calls:
|
||||
anchor = _row_anchor(record)
|
||||
http = (
|
||||
f"`{record.http_status}`"
|
||||
if record.http_status is not None
|
||||
else "`-`"
|
||||
)
|
||||
parts.append(
|
||||
f"| [{record.index}](#{anchor}) | `{record.method}` | "
|
||||
f"`{record.path}` | `{record.source}` | {http} |"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
for record in ctx.calls:
|
||||
parts.append(f"## {record.index} {record.method} {record.path}")
|
||||
parts.append("")
|
||||
parts.append(f"- Source: `{record.source}`")
|
||||
parts.append(f"- Transport: `{record.transport}`")
|
||||
if record.http_status is not None:
|
||||
parts.append(f"- HTTP status: `{record.http_status}`")
|
||||
else:
|
||||
parts.append("- HTTP status: `-`")
|
||||
if record.error:
|
||||
parts.append(f"- Error: {record.error}")
|
||||
parts.append("")
|
||||
parts.append("### Request Body")
|
||||
parts.append("")
|
||||
parts.append("```json")
|
||||
parts.append(_to_json_block(record.request_body))
|
||||
parts.append("```")
|
||||
parts.append("")
|
||||
parts.append("### Response Body")
|
||||
parts.append("")
|
||||
parts.append("```json")
|
||||
parts.append(_to_json_block(record.response_body))
|
||||
parts.append("```")
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
|
||||
def _row_anchor(record: CallRecord) -> str:
|
||||
"""Build a GitHub-style anchor matching ``## N METHOD /path``."""
|
||||
raw = f"{record.index}-{record.method}-{record.path}"
|
||||
raw = raw.lower()
|
||||
raw = re.sub(r"[^a-z0-9]+", "-", raw)
|
||||
return raw.strip("-")
|
||||
|
||||
|
||||
def _to_json_block(value: Any) -> str:
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
except TypeError:
|
||||
return json.dumps(str(value), ensure_ascii=False, indent=2)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .peptide_station import BioyondPeptideStation, fetch_workflow_list, load_peptide_config
|
||||
|
||||
__all__ = ["BioyondPeptideStation", "fetch_workflow_list", "load_peptide_config"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,681 @@
|
||||
"""多肽站 AST/参数/结果表 离线契约测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[6]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
MODULE_PATH = "unilabos.devices.workstation.bioyond_studio.peptide_station.peptide_station"
|
||||
CLASS_NAME = "BioyondPeptideStation"
|
||||
|
||||
ORDER_GUID = "3a20eabe-bad5-ef95-49bd-7ffbd5df189d"
|
||||
CREATE_ALLOCATION = {
|
||||
ORDER_GUID: [
|
||||
{
|
||||
"materialId": "mat-tip",
|
||||
"materialName": "200μL枪头盒",
|
||||
"materialCode": "0008-00105",
|
||||
"quantity": "1个",
|
||||
"materialTypeMode": "Consumables",
|
||||
"locationCode": "1-01",
|
||||
"locationShowName": "1-01",
|
||||
},
|
||||
{
|
||||
"materialId": "mat-plate",
|
||||
"materialName": "96孔板",
|
||||
"materialCode": "PLATE-96",
|
||||
"quantity": "1",
|
||||
"materialTypeMode": "Sample",
|
||||
"locationCode": "A1",
|
||||
"locationShowName": "A1-show",
|
||||
},
|
||||
{
|
||||
"materialId": "mat-extra",
|
||||
"materialName": "未知耗材",
|
||||
"materialCode": "X-1",
|
||||
"quantity": "2",
|
||||
"materialTypeMode": "Future",
|
||||
"locationCode": "Z9",
|
||||
"locationShowName": "",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
FLATTENED_LIVE = [
|
||||
{"step": "39c78d4b-b5d3-f721-2001-9d52000084c3", "step_name": "S1", "Key": "SampleFile", "m": 0, "n": 0, "Value": "", "DisplayValue": "", "TaskDisplayable": 1},
|
||||
{"step": "39c78d4b-b5d3-f721-2001-9d52000084c3", "step_name": "S1", "Key": "Example", "m": 0, "n": 0, "Value": "x", "DisplayValue": "x", "TaskDisplayable": 1},
|
||||
{"step": "39c78d4b-b5d3-f721-2001-9d52000084c4", "step_name": "S2", "Key": "protocol", "m": 14, "n": 28, "Value": "", "DisplayValue": "", "TaskDisplayable": 1},
|
||||
{"step": "39c78d4b-b5d3-f721-2001-9d52000084c5", "step_name": "S3", "Key": "CEMMethodFileName", "m": 0, "n": 0, "Value": "", "DisplayValue": "", "TaskDisplayable": 1},
|
||||
]
|
||||
|
||||
|
||||
def _import_module() -> Any:
|
||||
return importlib.import_module(MODULE_PATH)
|
||||
|
||||
|
||||
def _make_station() -> Any:
|
||||
module = _import_module()
|
||||
cls = getattr(module, CLASS_NAME)
|
||||
station = object.__new__(cls)
|
||||
station.bioyond_config = {"api_host": "http://test", "api_key": "k", "warehouse_mapping": {}}
|
||||
rpc = MagicMock()
|
||||
rpc.host = "http://test"
|
||||
rpc.api_key = "k"
|
||||
rpc.material_info.return_value = {"locations": [{"whName": "自动化堆栈", "code": "1-01"}]}
|
||||
station.hardware_interface = rpc
|
||||
return station
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. AST/导入面
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_required_actions_exposed() -> None:
|
||||
cls = getattr(_import_module(), CLASS_NAME)
|
||||
required = {
|
||||
"upload_sample_excel",
|
||||
"list_sample_excels",
|
||||
"get_step_parameters",
|
||||
"submit_experiment",
|
||||
"submit_experiment_day1",
|
||||
"submit_experiment_day2",
|
||||
"submit_experiment_day3",
|
||||
"submit_experiment_day4",
|
||||
"submit_experiment_day4_LCMS",
|
||||
"start_experiment",
|
||||
"reset",
|
||||
"scheduler_start",
|
||||
"scheduler_stop",
|
||||
"scheduler_pause",
|
||||
"scheduler_continue",
|
||||
"get_order_list",
|
||||
"get_order_report",
|
||||
"get_aggregated_order_report",
|
||||
}
|
||||
have = {name for name, _ in inspect.getmembers(cls, inspect.isfunction)}
|
||||
missing = sorted(required - have)
|
||||
assert not missing, f"缺少动作: {missing}"
|
||||
|
||||
|
||||
def test_manual_confirm_node_types() -> None:
|
||||
module = _import_module()
|
||||
cls = getattr(module, CLASS_NAME)
|
||||
manual = {"submit_experiment_day1", "start_experiment"}
|
||||
normal = {
|
||||
"submit_experiment",
|
||||
"submit_experiment_day2",
|
||||
"submit_experiment_day3",
|
||||
"submit_experiment_day4",
|
||||
"submit_experiment_day4_LCMS",
|
||||
"reset",
|
||||
"scheduler_start",
|
||||
"list_sample_excels",
|
||||
"get_step_parameters",
|
||||
"get_order_list",
|
||||
"get_order_report",
|
||||
}
|
||||
for name in manual:
|
||||
meta = getattr(getattr(cls, name), "_action_registry_meta", {})
|
||||
assert meta.get("node_type") == module.NodeType.MANUAL_CONFIRM, name
|
||||
for name in normal:
|
||||
meta = getattr(getattr(cls, name), "_action_registry_meta", {})
|
||||
assert meta.get("node_type") != module.NodeType.MANUAL_CONFIRM, name
|
||||
|
||||
|
||||
def test_submit_and_reset_signatures_exclude_legacy_manual_confirm() -> None:
|
||||
cls = getattr(_import_module(), CLASS_NAME)
|
||||
for name in (
|
||||
"submit_experiment",
|
||||
"submit_experiment_day2",
|
||||
"submit_experiment_day3",
|
||||
"submit_experiment_day4",
|
||||
"submit_experiment_day4_LCMS",
|
||||
"reset",
|
||||
):
|
||||
params = inspect.signature(getattr(cls, name)).parameters
|
||||
assert "timeout_seconds" not in params, name
|
||||
assert "assignee_user_ids" not in params, name
|
||||
|
||||
|
||||
def test_day1_submit_accepts_manual_confirm_kwargs() -> None:
|
||||
"""plan: Day1 是 MANUAL_CONFIRM;框架会注入 timeout_seconds/assignee_user_ids,函数必须能接收。"""
|
||||
cls = getattr(_import_module(), CLASS_NAME)
|
||||
sig = inspect.signature(cls.submit_experiment_day1)
|
||||
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
||||
assert has_kwargs, "submit_experiment_day1 必须有 **kwargs 以容纳人工确认框架字段"
|
||||
|
||||
|
||||
def test_typed_dicts_present() -> None:
|
||||
module = _import_module()
|
||||
for cls_name in (
|
||||
"PeptideGenericSubmitRequiredParams",
|
||||
"PeptideGenericSubmitOptionalParams",
|
||||
"PeptideDay1RequiredParams",
|
||||
"PeptideDay1OptionalParams",
|
||||
"PeptideDay2RequiredParams",
|
||||
"PeptideDay2OptionalParams",
|
||||
"PeptideDay3RequiredParams",
|
||||
"PeptideDay3OptionalParams",
|
||||
"PeptideDay4RequiredParams",
|
||||
"PeptideDay4OptionalParams",
|
||||
"PeptideDay4LCMSRequiredParams",
|
||||
"PeptideDay4LCMSOptionalParams",
|
||||
):
|
||||
assert hasattr(module, cls_name), cls_name
|
||||
|
||||
|
||||
def test_workflow_constants_split() -> None:
|
||||
module = _import_module()
|
||||
assert module.DAY4_PEPTIDE_WORKFLOW_NAME == "Day4环肽酰化-酶标"
|
||||
assert module.DAY4_LCMS_PEPTIDE_WORKFLOW_NAME == "Day4环肽酰化-酶标+LCMS"
|
||||
assert module.DAY_WORKFLOW_BINDINGS["day4_lcms"]["sub_name"] == "Day4环肽酰化-酶标LCMS"
|
||||
assert module.DAY1_CEM_METHOD_DEFAULT == "5microdouble-20250911.MPM"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Sample Excel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_sample_excels_modes() -> None:
|
||||
station = _make_station()
|
||||
records = [
|
||||
{"fileName": "DPR019-a.xlsx", "relativePath": "upload\\sample\\DPR019-a.xlsx"},
|
||||
{"fileName": "DPR019-b.xlsx", "relativePath": "upload\\sample\\DPR019-b.xlsx"},
|
||||
]
|
||||
station._list_sample_excels = MagicMock(return_value=records) # type: ignore[method-assign]
|
||||
|
||||
info = station.list_sample_excels(sample_excel_pattern="DPR019-a", deterministic_resolve=False)
|
||||
assert "sample_excel_data" in info
|
||||
assert "sample_excel_relative_path" not in info
|
||||
|
||||
resolved = station.list_sample_excels(sample_excel_pattern="DPR019-a", deterministic_resolve=True)
|
||||
assert resolved["sample_excel_relative_path"] == "upload\\sample\\DPR019-a.xlsx"
|
||||
|
||||
with pytest.raises(Exception):
|
||||
station.list_sample_excels(sample_excel_pattern="DPR019", deterministic_resolve=True)
|
||||
|
||||
|
||||
def test_resolve_submit_sample_file_direct_path() -> None:
|
||||
station = _make_station()
|
||||
relative, selected = station._resolve_submit_sample_file({}, {}, "upload/sample/x.xlsx")
|
||||
assert relative == "upload\\sample\\x.xlsx"
|
||||
assert selected["fileName"] == "x.xlsx"
|
||||
|
||||
|
||||
def test_filename_matches_pattern_substring_and_glob() -> None:
|
||||
station = _make_station()
|
||||
assert station._filename_matches_pattern("DPR019-20260421-thrombin-5.xlsx", "DPR019")
|
||||
assert station._filename_matches_pattern("a.xlsx", "*.xlsx")
|
||||
assert not station._filename_matches_pattern("a.xlsx", "*.docx")
|
||||
assert station._filename_matches_pattern("a.xlsx", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Step parameter helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filter_step_parameters_preserves_zero_and_skips_unknown() -> None:
|
||||
station = _make_station()
|
||||
records = [
|
||||
{"TaskDisplayable": 1, "Value": 0, "DisplayValue": ""},
|
||||
{"TaskDisplayable": 1, "Value": "", "DisplayValue": ""},
|
||||
{"TaskDisplayable": 0, "Value": "", "DisplayValue": ""},
|
||||
{"TaskDisplayable": None, "Value": "", "DisplayValue": ""},
|
||||
]
|
||||
filtered = station._filter_step_parameter_records(records, True, True, True)
|
||||
assert {(r.get("Value"), r.get("TaskDisplayable")) for r in filtered} == {(0, 1), ("", 1), ("", 0)}
|
||||
|
||||
|
||||
def test_get_step_parameters_zero_match_returns_status() -> None:
|
||||
station = _make_station()
|
||||
station._query_workflow_records = MagicMock(return_value=[]) # type: ignore[method-assign]
|
||||
out = station.get_step_parameters(workflow_name_filter="不存在")
|
||||
status = out["step_parameters_raw_json"]
|
||||
assert status.get("code") == -1
|
||||
assert out["filtered_subworkflows"] == []
|
||||
|
||||
|
||||
def test_get_step_parameters_multi_match_returns_status() -> None:
|
||||
station = _make_station()
|
||||
station._query_workflow_records = MagicMock(return_value=[ # type: ignore[method-assign]
|
||||
{"workflowId": "w1", "workflowName": "A", "subworkflowId": "s1", "subworkflowName": "A1"},
|
||||
{"workflowId": "w1", "workflowName": "A", "subworkflowId": "s2", "subworkflowName": "A2"},
|
||||
])
|
||||
out = station.get_step_parameters(workflow_name_filter="A")
|
||||
assert out["step_parameters_raw_json"].get("code") == 0
|
||||
assert len(out["filtered_subworkflows"]) == 2
|
||||
|
||||
|
||||
def test_get_step_parameters_direct_sub_workflow_id() -> None:
|
||||
station = _make_station()
|
||||
station._query_step_parameters = MagicMock(return_value={ # type: ignore[method-assign]
|
||||
"39c78d4b-b5d3-f721-2001-9d52000084c3": [
|
||||
{"name": "S1", "m": 0, "n": 0, "parameterList": [
|
||||
{"Key": "SampleFile", "TaskDisplayable": 1, "Value": "", "DisplayValue": ""},
|
||||
]},
|
||||
]
|
||||
})
|
||||
out = station.get_step_parameters(sub_workflow_id="39c78d4b-b5d3-f721-2001-9d52000084c3")
|
||||
augmented = out["step_parameters_raw_json"]
|
||||
assert augmented["code"] == 1
|
||||
assert any(p["Key"] == "SampleFile" for p in augmented["data"]["filteredParameters"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Partial parameter entries + live resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_partial_entries_inject_samplefile_and_overrides() -> None:
|
||||
station = _make_station()
|
||||
entries, warnings = station._build_partial_parameter_entries(
|
||||
sample_excel_relative_path="upload\\sample\\f.xlsx",
|
||||
day_key="day2",
|
||||
parameter_overrides=[{"Key": "Example", "Value": 0}],
|
||||
)
|
||||
assert entries[0] == {"Key": "SampleFile", "Value": "upload\\sample\\f.xlsx"}
|
||||
assert any(e["Key"] == "Example" and e["Value"] == 0 for e in entries)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_day1_partial_entries_inject_cem_default() -> None:
|
||||
station = _make_station()
|
||||
entries, _ = station._build_partial_parameter_entries(
|
||||
sample_excel_relative_path="upload\\sample\\f.xlsx",
|
||||
day_key="day1",
|
||||
extra_autofill=[{"Key": "CEMMethodFileName", "Value": "5microdouble-20250911.MPM"}],
|
||||
)
|
||||
assert any(e["Key"] == "CEMMethodFileName" and e["Value"] == "5microdouble-20250911.MPM" for e in entries)
|
||||
|
||||
|
||||
def test_overrides_duplicate_last_write_wins_warning() -> None:
|
||||
station = _make_station()
|
||||
entries, warnings = station._build_partial_parameter_entries(
|
||||
sample_excel_relative_path="x",
|
||||
day_key="day2",
|
||||
parameter_overrides=[
|
||||
{"Key": "Example", "m": 0, "n": 0, "Value": "first"},
|
||||
{"Key": "Example", "m": 0, "n": 0, "Value": "second"},
|
||||
],
|
||||
)
|
||||
example_entries = [e for e in entries if e["Key"] == "Example"]
|
||||
assert len(example_entries) == 1
|
||||
assert example_entries[0]["Value"] == "second"
|
||||
assert any("重复" in w for w in warnings)
|
||||
|
||||
|
||||
def test_resolve_against_live_unique_match_and_failure() -> None:
|
||||
station = _make_station()
|
||||
resolved = station._resolve_parameter_entries_against_live_steps(
|
||||
[{"Key": "SampleFile", "Value": "upload\\sample\\f.xlsx"}], FLATTENED_LIVE
|
||||
)
|
||||
assert resolved[0]["step"] == "39c78d4b-b5d3-f721-2001-9d52000084c3"
|
||||
assert resolved[0]["m"] == 0 and resolved[0]["n"] == 0
|
||||
# 没有 protocol 在 m/n=0/0 处 → 0 匹配
|
||||
with pytest.raises(Exception) as exc:
|
||||
station._resolve_parameter_entries_against_live_steps(
|
||||
[{"Key": "protocol", "m": 0, "n": 0, "Value": "v"}], FLATTENED_LIVE
|
||||
)
|
||||
assert "0 条" in str(exc.value)
|
||||
|
||||
|
||||
def test_group_resolved_entries_uses_lowercase_keys() -> None:
|
||||
station = _make_station()
|
||||
grouped = station._group_resolved_entries_to_param_values([
|
||||
{"step": "39c78d4b-b5d3-f721-2001-9d52000084c3", "Key": "SampleFile", "m": 0, "n": 0, "Value": "x"},
|
||||
])
|
||||
step_entries = grouped["39c78d4b-b5d3-f721-2001-9d52000084c3"]
|
||||
assert step_entries[0] == {"key": "SampleFile", "value": "x", "m": 0, "n": 0}
|
||||
|
||||
|
||||
def test_create_order_payload_shape() -> None:
|
||||
station = _make_station()
|
||||
payload = station._create_order_payload(
|
||||
order_code="EXP260518-103000",
|
||||
order_name="实验260518-103000",
|
||||
sub_workflow_id="3a1d35f9-63ce-67d6-1784-9f6abcca4eda",
|
||||
param_values={"39c78d4b-b5d3-f721-2001-9d52000084c3": [{"key": "SampleFile", "value": "x", "m": 0, "n": 0}]},
|
||||
border_number=1,
|
||||
extend_properties=None,
|
||||
)
|
||||
assert isinstance(payload, list) and len(payload) == 1
|
||||
item = payload[0]
|
||||
assert item["workFlowId"] == "3a1d35f9-63ce-67d6-1784-9f6abcca4eda"
|
||||
assert item["paramValues"]
|
||||
assert item["extendProperties"] == ""
|
||||
assert item["borderNumber"] == 1
|
||||
|
||||
|
||||
def test_order_identity_format() -> None:
|
||||
station = _make_station()
|
||||
code, name = station._build_order_identity("day2")
|
||||
assert code.startswith("EXP") and len(code) == 16 # EXP + YYMMDD-HHmmss
|
||||
assert name.startswith("实验")
|
||||
code2, name2 = station._build_order_identity("day2", "自定义")
|
||||
assert name2 == "自定义"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Generic submit / day wrappers (含会抦住 BUG 1 的用例)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _wire_submit_pipeline(station: Any) -> None:
|
||||
station._resolve_workflow_binding_from_names = MagicMock(return_value={ # type: ignore[method-assign]
|
||||
"workflow_name": "DAY2多肽定量",
|
||||
"root_workflow_id": "3a1d35f0-9436-895b-2eda-039a5465275e",
|
||||
"sub_workflow_id": "3a1d35f0-9f7e-c2c1-0bc0-8d94b81d90ca",
|
||||
"sub_workflow_name": "DAY2多肽定量",
|
||||
"raw": {},
|
||||
})
|
||||
station._resolve_workflow_binding = MagicMock(side_effect=lambda day_key: station._resolve_workflow_binding_from_names("DAY2多肽定量")) # type: ignore[method-assign]
|
||||
station._query_step_parameters = MagicMock(return_value={}) # type: ignore[method-assign]
|
||||
station._flatten_step_parameters = MagicMock(return_value=FLATTENED_LIVE) # type: ignore[method-assign]
|
||||
station._create_order = MagicMock(return_value=json.dumps(CREATE_ALLOCATION)) # type: ignore[method-assign]
|
||||
|
||||
|
||||
def test_submit_experiment_generic_succeeds() -> None:
|
||||
"""plan §「Generic And Day 1 Submit」line 919-924;这条同时抦住 BUG 1(binding= 关键字)。"""
|
||||
station = _make_station()
|
||||
_wire_submit_pipeline(station)
|
||||
result = station.submit_experiment(
|
||||
{"workflow_name": "DAY2多肽定量", "sample_excel_pattern": ""},
|
||||
{"parameter_overrides": []},
|
||||
sample_excel_relative_path="upload/sample/f.xlsx",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["order_id"] == ORDER_GUID
|
||||
assert result["resultTable"]["tableName"] == "resultTable"
|
||||
|
||||
|
||||
def test_submit_experiment_rejects_day1_alias() -> None:
|
||||
station = _make_station()
|
||||
with pytest.raises(Exception):
|
||||
station.submit_experiment(
|
||||
{"workflow_name": "Day1线肽合成", "sample_excel_pattern": "x"},
|
||||
{},
|
||||
sample_excel_relative_path="upload/sample/f.xlsx",
|
||||
)
|
||||
|
||||
|
||||
def test_submit_experiment_day2_calls_pipeline() -> None:
|
||||
station = _make_station()
|
||||
_wire_submit_pipeline(station)
|
||||
result = station.submit_experiment_day2(
|
||||
{"sample_excel_pattern": ""},
|
||||
{"parameter_overrides": []},
|
||||
sample_excel_relative_path="upload/sample/f.xlsx",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["order_ids"] == [ORDER_GUID]
|
||||
assert result["auto_register_materials"] is True
|
||||
assert result["material_registration"]["status"] == "not_implemented"
|
||||
|
||||
|
||||
def test_day1_placeholder_does_not_call_create_order() -> None:
|
||||
station = _make_station()
|
||||
station._resolve_workflow_binding = MagicMock(return_value={ # type: ignore[method-assign]
|
||||
"workflow_name": "Day1线肽合成",
|
||||
"root_workflow_id": "rid",
|
||||
"sub_workflow_id": "sid",
|
||||
"sub_workflow_name": "Day1线肽合成",
|
||||
"raw": {},
|
||||
})
|
||||
station._create_order = MagicMock(side_effect=AssertionError("Day1 不应触达 create_order")) # type: ignore[method-assign]
|
||||
out = station.submit_experiment_day1(
|
||||
{"sample_excel_pattern": "", "cem_method_file_name": ""},
|
||||
{},
|
||||
sample_excel_relative_path="upload/sample/f.xlsx",
|
||||
# 模拟人工确认框架注入的字段(这条会抦住 BUG 3)
|
||||
timeout_seconds=3600,
|
||||
assignee_user_ids=[],
|
||||
materials_loaded=False,
|
||||
)
|
||||
assert out["status"] == "manual_confirm_placeholder"
|
||||
assert out["cem_method_file_name"] == "5microdouble-20250911.MPM"
|
||||
assert isinstance(out["partial_parameter_entries"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Allocation map parsing + resultTable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_allocation_map_extracts_order_id_and_groups() -> None:
|
||||
station = _make_station()
|
||||
parsed = station._parse_create_order_allocation_map(json.dumps(CREATE_ALLOCATION))
|
||||
assert parsed["order_ids"] == [ORDER_GUID]
|
||||
assert len(parsed["allocation_rows"]) == 3
|
||||
assert set(parsed["materials_by_type"].keys()) == {"Consumables", "Sample", "Future"}
|
||||
|
||||
|
||||
def test_parse_allocation_map_handles_python_str_repr() -> None:
|
||||
"""RPC.create_order 返回的是 str(dict),含单引号。"""
|
||||
station = _make_station()
|
||||
parsed = station._parse_create_order_allocation_map(str(CREATE_ALLOCATION))
|
||||
assert parsed["order_ids"] == [ORDER_GUID]
|
||||
|
||||
|
||||
def test_parse_allocation_map_empty() -> None:
|
||||
station = _make_station()
|
||||
parsed = station._parse_create_order_allocation_map("{}")
|
||||
assert parsed["allocation_rows"] == []
|
||||
assert parsed["order_ids"] == []
|
||||
|
||||
|
||||
def test_build_result_table_order_and_columns() -> None:
|
||||
station = _make_station()
|
||||
parsed = station._parse_create_order_allocation_map(json.dumps(CREATE_ALLOCATION))
|
||||
table = station._build_result_table(parsed["materials_by_type"])
|
||||
assert table["tableName"] == "resultTable"
|
||||
assert [c["key"] for c in table["columns"]] == ["whName", "locationCode", "materialName", "quantity"]
|
||||
# 顺序:Sample → Consumables → Future(未知 mode 保留在末尾)
|
||||
names = [row["materialName"] for row in table["data"]]
|
||||
assert names == ["96孔板", "200μL枪头盒", "未知耗材"]
|
||||
# locationShowName 优先 locationCode
|
||||
assert table["data"][0]["locationCode"] == "A1-show"
|
||||
assert table["data"][1]["locationCode"] == "1-01"
|
||||
|
||||
|
||||
def test_build_result_table_empty_returns_empty_data() -> None:
|
||||
station = _make_station()
|
||||
table = station._build_result_table({})
|
||||
assert table["data"] == []
|
||||
assert [c["key"] for c in table["columns"]] == ["whName", "locationCode", "materialName", "quantity"]
|
||||
|
||||
|
||||
def test_resolve_wh_name_handles_material_info_failure() -> None:
|
||||
station = _make_station()
|
||||
station.hardware_interface.material_info.side_effect = RuntimeError("HTTP 500")
|
||||
cache: Dict[str, Dict[str, Any]] = {}
|
||||
assert station._resolve_wh_name_by_material_id("mat-1", cache) == ""
|
||||
|
||||
|
||||
def test_submit_returns_warning_when_allocation_empty() -> None:
|
||||
station = _make_station()
|
||||
_wire_submit_pipeline(station)
|
||||
station._create_order = MagicMock(return_value="{}") # type: ignore[method-assign]
|
||||
result = station.submit_experiment_day2(
|
||||
{"sample_excel_pattern": ""},
|
||||
{},
|
||||
sample_excel_relative_path="upload/sample/f.xlsx",
|
||||
)
|
||||
assert "create_order_allocation_unavailable_for_result_table" in result["warnings"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Reports + workflow records
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_order_list_passes_json_string() -> None:
|
||||
station = _make_station()
|
||||
station.hardware_interface.order_query.return_value = {"items": [], "totalCount": 0}
|
||||
station.get_order_list(filter_text="abc", page_count=10)
|
||||
args, kwargs = station.hardware_interface.order_query.call_args
|
||||
payload = json.loads(args[0])
|
||||
assert payload["filter"] == "abc"
|
||||
assert payload["pageCount"] == 10
|
||||
|
||||
|
||||
def test_get_order_report_calls_typed_rpc() -> None:
|
||||
station = _make_station()
|
||||
station.hardware_interface.order_report.return_value = {"id": ORDER_GUID, "name": "x", "preIntakes": [], "resultList": []}
|
||||
out = station.get_order_report(ORDER_GUID)
|
||||
station.hardware_interface.order_report.assert_called_once_with(ORDER_GUID)
|
||||
assert out["success"] is True
|
||||
assert out["summary"]["id"] == ORDER_GUID
|
||||
|
||||
|
||||
def test_get_aggregated_order_report_is_todo_placeholder() -> None:
|
||||
station = _make_station()
|
||||
out = station.get_aggregated_order_report(ORDER_GUID)
|
||||
assert out["status"] == "not_implemented"
|
||||
|
||||
|
||||
def test_query_workflow_records_filters_unsaved_subworkflows() -> None:
|
||||
station = _make_station()
|
||||
station.hardware_interface.query_workflow.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"id": "rid",
|
||||
"name": "Day3线肽环化",
|
||||
"subWorkflows": [
|
||||
{"id": "saved-id", "name": "Day3线肽环化", "isSaved": True},
|
||||
{"id": "draft-id", "name": "Day3线肽环化-草稿", "isSaved": False},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
records = station._query_workflow_records("Day3线肽环化")
|
||||
assert [r["subworkflowId"] for r in records] == ["saved-id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Debug / fetch_workflow_list 守护
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_module_fetch_workflow_list_is_debug_guarded() -> None:
|
||||
module = _import_module()
|
||||
assert module.DEBUG_CLI_ENABLED is False
|
||||
with pytest.raises(AssertionError):
|
||||
module.fetch_workflow_list(config={"api_host": "http://x", "api_key": "k"})
|
||||
|
||||
|
||||
def test_station_fetch_workflow_list_uses_rpc() -> None:
|
||||
station = _make_station()
|
||||
station.hardware_interface.query_workflow.return_value = {"items": [], "totalCount": 0}
|
||||
station.fetch_workflow_list(filter_text="Day2")
|
||||
args, _ = station.hardware_interface.query_workflow.call_args
|
||||
payload = json.loads(args[0])
|
||||
assert payload["filter"] == "Day2"
|
||||
assert payload["includeDetail"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. start_experiment 装载闸门
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_start_experiment_blocks_when_materials_not_loaded() -> None:
|
||||
station = _make_station()
|
||||
station.hardware_interface.scheduler_start.return_value = 1
|
||||
with pytest.raises(RuntimeError):
|
||||
station.start_experiment(
|
||||
order_id=ORDER_GUID,
|
||||
resultTable={"data": [{"materialName": "x"}]},
|
||||
materials_loaded=False,
|
||||
)
|
||||
|
||||
|
||||
def test_start_experiment_starts_when_table_empty() -> None:
|
||||
station = _make_station()
|
||||
station.hardware_interface.scheduler_start.return_value = 1
|
||||
result = station.start_experiment(order_id=ORDER_GUID, resultTable={"data": []})
|
||||
assert result["success"] is True
|
||||
assert result["order_ids"] == [ORDER_GUID]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reset_signature_drops_legacy_params_and_uses_literal() -> None:
|
||||
"""plan 调整:删除 dry_run/order_id/location_id;reset_operations 用 Literal 注解。"""
|
||||
cls = getattr(_import_module(), CLASS_NAME)
|
||||
sig = inspect.signature(cls.reset)
|
||||
params = sig.parameters
|
||||
for legacy in ("dry_run", "order_id", "location_id"):
|
||||
assert legacy not in params, f"reset 不应再有 {legacy} 入参"
|
||||
assert "reset_operations" in params
|
||||
assert any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()), \
|
||||
"reset 必须保留 **kwargs 以兜底 reset_order_id/reset_location_id"
|
||||
|
||||
annotation = params["reset_operations"].annotation
|
||||
rendered = annotation if isinstance(annotation, str) else repr(annotation)
|
||||
for op in ("scheduler_reset", "reset_order_status", "reset_location"):
|
||||
assert op in rendered, f"reset_operations 的 Literal 必须包含 {op}"
|
||||
|
||||
|
||||
def test_reset_goal_default_contains_all_operations() -> None:
|
||||
"""像 sirna 一样,goal_default 默认勾选全部三个 reset 操作。"""
|
||||
cls = getattr(_import_module(), CLASS_NAME)
|
||||
meta = getattr(cls.reset, "_action_registry_meta", {})
|
||||
goal_default = meta.get("goal_default") or {}
|
||||
assert goal_default.get("reset_operations") == [
|
||||
"scheduler_reset",
|
||||
"reset_order_status",
|
||||
"reset_location",
|
||||
]
|
||||
|
||||
|
||||
def test_reset_executes_typed_rpc_calls() -> None:
|
||||
station = _make_station()
|
||||
station.hardware_interface.scheduler_reset.return_value = 1
|
||||
station.hardware_interface.reset_order_status.return_value = 1
|
||||
station.hardware_interface.reset_location.return_value = 1
|
||||
out = station.reset(
|
||||
reset_operations=["scheduler_reset", "reset_order_status", "reset_location"],
|
||||
reset_order_id=ORDER_GUID,
|
||||
reset_location_id="loc-1",
|
||||
)
|
||||
station.hardware_interface.scheduler_reset.assert_called_once_with()
|
||||
station.hardware_interface.reset_order_status.assert_called_once_with(ORDER_GUID)
|
||||
station.hardware_interface.reset_location.assert_called_once_with("loc-1")
|
||||
assert out["selected_operations"] == [
|
||||
"scheduler_reset",
|
||||
"reset_order_status",
|
||||
"reset_location",
|
||||
]
|
||||
assert len(out["executed_calls"]) == 3
|
||||
assert out["skipped_operations"] == []
|
||||
|
||||
|
||||
def test_reset_skips_when_ids_missing() -> None:
|
||||
"""没有 order_id / location_id 时应该 skip 而不是抛错。"""
|
||||
station = _make_station()
|
||||
station.hardware_interface.scheduler_reset.return_value = 1
|
||||
out = station.reset(
|
||||
reset_operations=["scheduler_reset", "reset_order_status", "reset_location"],
|
||||
)
|
||||
station.hardware_interface.scheduler_reset.assert_called_once_with()
|
||||
station.hardware_interface.reset_order_status.assert_not_called()
|
||||
station.hardware_interface.reset_location.assert_not_called()
|
||||
skipped_ops = {item["operation"] for item in out["skipped_operations"]}
|
||||
assert skipped_ops == {"reset_order_status", "reset_location"}
|
||||
@@ -7,6 +7,7 @@ Bioyond Workstation Implementation
|
||||
import time
|
||||
import traceback
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
@@ -14,6 +15,7 @@ from pathlib import Path
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer
|
||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
|
||||
from unilabos.devices.workstation.bioyond_studio import debug_call_log
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from unilabos.resources.warehouse import WareHouse
|
||||
from unilabos.utils.log import logger
|
||||
@@ -54,13 +56,17 @@ class ConnectionMonitor:
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 使用 lightweight API 检查连接
|
||||
# query_matial_type_list 是比较快的查询
|
||||
start_time = time.time()
|
||||
result = self.workstation.hardware_interface.material_type_list()
|
||||
# 使用轻量级调度状态接口检查连接,避免启动时打印完整物料类型列表。
|
||||
result = self.workstation.hardware_interface.scheduler_status()
|
||||
|
||||
status = "online" if result else "offline"
|
||||
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||
if status == "online":
|
||||
msg = (
|
||||
f"Scheduler status={result.get('status')}, "
|
||||
f"hasTask={result.get('hasTask')}"
|
||||
)
|
||||
else:
|
||||
msg = "Failed to get scheduler status"
|
||||
|
||||
if status != self._last_status:
|
||||
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||
@@ -174,6 +180,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.warning("从Bioyond获取的物料数据为空")
|
||||
return False
|
||||
|
||||
self._update_material_cache_from_stock(all_bioyond_data)
|
||||
|
||||
# 转换为UniLab格式
|
||||
unilab_resources = resource_bioyond_to_plr(
|
||||
all_bioyond_data,
|
||||
@@ -187,6 +195,29 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.error(f"从Bioyond同步物料数据失败: {e}")
|
||||
return False
|
||||
|
||||
def _update_material_cache_from_stock(self, materials: List[Dict[str, Any]]) -> None:
|
||||
"""用本次库存查询结果同步 RPC 的 name -> material id 缓存。"""
|
||||
material_cache = getattr(self.bioyond_api_client, "material_cache", None)
|
||||
if not isinstance(material_cache, dict):
|
||||
return
|
||||
|
||||
before_count = len(material_cache)
|
||||
for material in materials:
|
||||
material_name = material.get("name")
|
||||
material_id = material.get("id")
|
||||
if material_name and material_id:
|
||||
material_cache[material_name] = material_id
|
||||
|
||||
for detail_material in material.get("detail", []) or []:
|
||||
detail_name = detail_material.get("name")
|
||||
detail_id = detail_material.get("detailMaterialId") or detail_material.get("id")
|
||||
if detail_name and detail_id:
|
||||
material_cache[detail_name] = detail_id
|
||||
|
||||
logger.debug(
|
||||
f"已用Bioyond库存同步物料缓存: {before_count} -> {len(material_cache)}"
|
||||
)
|
||||
|
||||
def sync_to_external(self, resource: Any) -> bool:
|
||||
"""将本地物料数据变更同步到Bioyond系统"""
|
||||
try:
|
||||
@@ -678,6 +709,70 @@ class BioyondWorkstation(WorkstationBase):
|
||||
集成Bioyond物料管理的工作站实现
|
||||
"""
|
||||
|
||||
# 子类(如 sirna / peptide)覆写以指定默认 raw-call 日志目录。
|
||||
# 路径相对仓库根;为 None 时若 debug_log=True 仍会写入临时位置。
|
||||
_DEBUG_LOG_DEFAULT_DIR: Optional[str] = None
|
||||
|
||||
def _create_bioyond_rpc(self, config: Dict[str, Any]) -> BioyondV1RPC:
|
||||
"""创建 Bioyond RPC 客户端并应用调试包装。
|
||||
|
||||
所有创建 ``BioyondV1RPC`` 的路径(饿汉初始化、Sirna 延迟初始化、
|
||||
以及未来的前端重新配置路径)都应通过该 helper,
|
||||
以确保 debug_log 包装与命名/日志策略保持一致。
|
||||
"""
|
||||
rpc = BioyondV1RPC(config)
|
||||
debug_call_log.wrap_rpc_http(rpc)
|
||||
return rpc
|
||||
|
||||
def _set_hardware_interface(self, rpc: BioyondV1RPC) -> BioyondV1RPC:
|
||||
"""将已构造的 RPC 客户端设置到 ``self.hardware_interface``,并应用调试包装。"""
|
||||
debug_call_log.wrap_rpc_http(rpc)
|
||||
self.hardware_interface = rpc
|
||||
return rpc
|
||||
|
||||
def _debug_log_resolved_dir(self) -> Path:
|
||||
"""解析 ``debug_log_dir`` 为绝对路径。"""
|
||||
configured = (getattr(self, "bioyond_config", {}) or {}).get("debug_log_dir")
|
||||
default_dir = getattr(self, "_DEBUG_LOG_DEFAULT_DIR", None)
|
||||
candidate = configured or default_dir or "bioyond_debug_records"
|
||||
path = Path(candidate)
|
||||
if not path.is_absolute():
|
||||
repo_root = Path(__file__).resolve().parents[4]
|
||||
path = repo_root / path
|
||||
return path
|
||||
|
||||
def _ensure_debug_log_state(self) -> None:
|
||||
"""从 ``self.bioyond_config`` 派生 ``_debug_log_enabled`` / ``_debug_log_dir``。
|
||||
|
||||
每次进入 ``_debug_call_session`` 时都重新解析,以兼容前端在运行时
|
||||
修改 ``bioyond_config['debug_log']`` 或目录的场景;同时也容忍
|
||||
子类(如 Sirna 延迟初始化)在 ``__init__`` 早期未触发本方法。
|
||||
"""
|
||||
cfg = getattr(self, "bioyond_config", {}) or {}
|
||||
self._debug_log_enabled = bool(cfg.get("debug_log"))
|
||||
self._debug_log_dir = self._debug_log_resolved_dir()
|
||||
|
||||
@contextmanager
|
||||
def _debug_call_session(self, action_name: str):
|
||||
"""在 action 体外加一层 debug 会话上下文。
|
||||
|
||||
- ``debug_log`` 关闭时是空上下文,开销为 0。
|
||||
- ``debug_log`` 开启时进入 :func:`debug_call_log.session`,所有
|
||||
已被 ``wrap_rpc_http`` 包装过的 RPC 客户端都会捕获本次 action
|
||||
产生的 HTTP 调用并写入 Markdown 文件。
|
||||
|
||||
子类(如 ``end_experiment``、``manual_unload`` 等)可以直接在
|
||||
action 体里以 ``with self._debug_call_session("action_name"):`` 包裹。
|
||||
"""
|
||||
cfg = getattr(self, "bioyond_config", {}) or {}
|
||||
enabled = bool(cfg.get("debug_log"))
|
||||
if not enabled:
|
||||
yield None
|
||||
return
|
||||
out_dir = BioyondWorkstation._debug_log_resolved_dir(self)
|
||||
with debug_call_log.session(action_name, out_dir) as ctx:
|
||||
yield ctx
|
||||
|
||||
def _publish_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -862,7 +957,7 @@ class BioyondWorkstation(WorkstationBase):
|
||||
self.bioyond_config = {}
|
||||
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
self.hardware_interface = self._create_bioyond_rpc(self.bioyond_config)
|
||||
|
||||
def resource_tree_add(self, resources: List[ResourcePLR]) -> None:
|
||||
"""添加资源到资源树并更新ROS节点
|
||||
@@ -1338,11 +1433,7 @@ class BioyondWorkstation(WorkstationBase):
|
||||
if self.hardware_interface:
|
||||
self.hardware_interface.scheduler_reset()
|
||||
|
||||
# 刷新物料缓存
|
||||
if self.hardware_interface:
|
||||
self.hardware_interface.refresh_material_cache()
|
||||
|
||||
# 重新同步资源
|
||||
# 重新同步资源,并用同一次库存查询结果更新物料缓存
|
||||
if self.resource_synchronizer:
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -198,6 +198,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
description: A JSON-formatted string that includes option, target,
|
||||
speed, lift_height, mt_height
|
||||
type: string
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
@@ -241,6 +243,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
|
||||
@@ -284,6 +288,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
|
||||
@@ -329,7 +334,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:
|
||||
|
||||
@@ -709,6 +709,8 @@ linear_motion.toyo_xyz.sim:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
description: A JSON-formatted string that includes option, target,
|
||||
speed, lift_height, mt_height
|
||||
type: string
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
@@ -752,6 +754,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
|
||||
@@ -795,6 +799,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <init_params>, data: <status_types> } ---
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$")
|
||||
_PARAM_HEADER_RE = re.compile(
|
||||
r"^\s*(?P<name>\w[\w]*)\s*(?:\[(?P<display_name>[^\]]+)\])?(?:\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
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
try:
|
||||
from . import peptide_materials # noqa: F401 ensure @resource classes are importable for PLR deserialize
|
||||
except Exception: # pragma: no cover - 允许轻量环境导入非资源辅助函数
|
||||
peptide_materials = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from . import sirna_materials # noqa: F401 ensure @resource classes are importable for PLR deserialize
|
||||
except Exception: # pragma: no cover - 允许轻量环境导入非资源辅助函数
|
||||
sirna_materials = None # type: ignore[assignment]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from os import name
|
||||
|
||||
from pylabrobot.resources import Deck, Coordinate, Rotation
|
||||
|
||||
from unilabos.registry.decorators import resource
|
||||
from unilabos.resources.bioyond.YB_warehouses import (
|
||||
bioyond_warehouse_1x4x4,
|
||||
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05~D08)
|
||||
@@ -23,6 +25,11 @@ from unilabos.resources.bioyond.YB_warehouses import (
|
||||
from unilabos.resources.bioyond.warehouses import (
|
||||
bioyond_warehouse_tipbox_storage_left, # 新增:Tip盒堆栈(左)
|
||||
bioyond_warehouse_tipbox_storage_right, # 新增:Tip盒堆栈(右)
|
||||
bioyond_warehouse_sirna_automation_stack,
|
||||
bioyond_warehouse_sirna_centrifuge_balance_plate_stack,
|
||||
bioyond_warehouse_sirna_g3_liquid_handler,
|
||||
bioyond_warehouse_numeric_stack, # 新增:数字编码堆栈 (用于多肽站)
|
||||
bioyond_warehouse_live_grid,
|
||||
)
|
||||
|
||||
|
||||
@@ -101,6 +108,83 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
@resource(
|
||||
id="BIOYOND_SirnaStation_Deck",
|
||||
category=["deck"],
|
||||
description="BIOYOND 小核酸工作站 Deck",
|
||||
icon="配液站.webp",
|
||||
)
|
||||
class BIOYOND_SirnaStation_Deck(Deck):
|
||||
WAREHOUSE_BIOYOND_AXIS = {
|
||||
"G3移液站": "xy_col_row",
|
||||
"自动化堆栈": "xy_col_row",
|
||||
"离心机配平板堆栈": "xy_col_row",
|
||||
}
|
||||
WAREHOUSE_BIOYOND_KEY_AXIS = {
|
||||
"G3移液站": "col_row",
|
||||
"自动化堆栈": "col_row",
|
||||
"离心机配平板堆栈": "col_row",
|
||||
}
|
||||
# Bioyond warehouse UUID -> 本地仓库名称 映射。
|
||||
# 留空时由配置(station config 的 ``warehouse_bioyond_ids``)注入。
|
||||
# graph 节点也可在 deck.config.warehouse_bioyond_ids 覆盖。
|
||||
WAREHOUSE_BIOYOND_IDS: dict = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "SirnaStation_Deck",
|
||||
size_x: float = 2700.0,
|
||||
size_y: float = 1080.0,
|
||||
size_z: float = 1500.0,
|
||||
category: str = "deck",
|
||||
setup: bool = False,
|
||||
warehouse_bioyond_ids: dict | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
||||
# 按需写入实例级覆盖;保留默认空 mapping,避免改动模型常量。
|
||||
self.warehouse_bioyond_ids: dict = dict(self.WAREHOUSE_BIOYOND_IDS)
|
||||
if warehouse_bioyond_ids:
|
||||
self.warehouse_bioyond_ids.update(warehouse_bioyond_ids)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal: bool = False):
|
||||
if data.get("children") and data.get("setup") is True:
|
||||
data = data.copy()
|
||||
data["setup"] = False
|
||||
result = super().deserialize(data, allow_marshal=allow_marshal)
|
||||
result._ensure_sirna_warehouse_metadata()
|
||||
return result
|
||||
|
||||
def _ensure_sirna_warehouse_metadata(self) -> None:
|
||||
for child in getattr(self, "children", []):
|
||||
name = getattr(child, "name", "")
|
||||
axis = self.WAREHOUSE_BIOYOND_AXIS.get(name)
|
||||
if axis and not hasattr(child, "bioyond_axis"):
|
||||
child.bioyond_axis = axis
|
||||
key_axis = self.WAREHOUSE_BIOYOND_KEY_AXIS.get(name)
|
||||
if key_axis and not hasattr(child, "bioyond_key_axis"):
|
||||
child.bioyond_key_axis = key_axis
|
||||
|
||||
def setup(self) -> None:
|
||||
# Sirna 读接口 /api/storage/location/locations-by-type 返回完整固定堆栈清单。
|
||||
# LIMS 在库物料接口仍使用相同的 自动化堆栈 名称和数字库位编码。
|
||||
self.warehouses = {
|
||||
"G3移液站": bioyond_warehouse_sirna_g3_liquid_handler(),
|
||||
"自动化堆栈": bioyond_warehouse_sirna_automation_stack(),
|
||||
"离心机配平板堆栈": bioyond_warehouse_sirna_centrifuge_balance_plate_stack(),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"G3移液站": Coordinate(0.0, 0.0, 0.0),
|
||||
"自动化堆栈": Coordinate(220.0, 0.0, 0.0),
|
||||
"离心机配平板堆栈": Coordinate(1740.0, 0.0, 0.0),
|
||||
}
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
class BIOYOND_YB_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -150,12 +234,207 @@ class BIOYOND_YB_Deck(Deck):
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
@resource(
|
||||
id="BIOYOND_PeptideStation_Deck",
|
||||
category=["deck"],
|
||||
description="BIOYOND 多肽工作站 Deck",
|
||||
icon="preparation_station.webp",
|
||||
)
|
||||
class BIOYOND_PeptideStation_Deck(Deck):
|
||||
WAREHOUSE_BIOYOND_AXIS = dict.fromkeys(
|
||||
[
|
||||
"自动化堆栈",
|
||||
"低温冰箱仓库",
|
||||
"Tecan移液站库",
|
||||
"G3移液站库",
|
||||
"IDOT移液站库",
|
||||
"G3缓冲库",
|
||||
"盖板缓冲库",
|
||||
"配平板缓冲库",
|
||||
"IDOT缓冲库",
|
||||
"固相合成板底座缓冲位",
|
||||
"离心机库位",
|
||||
"热封膜机位",
|
||||
],
|
||||
"xy_col_row",
|
||||
)
|
||||
WAREHOUSE_BIOYOND_KEY_AXIS = dict.fromkeys(WAREHOUSE_BIOYOND_AXIS, "col_row")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "PeptideStation_Deck",
|
||||
size_x: float = 2700.0,
|
||||
size_y: float = 2000.0,
|
||||
size_z: float = 1500.0,
|
||||
category: str = "deck",
|
||||
setup: bool = False
|
||||
) -> None:
|
||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal: bool = False):
|
||||
if data.get("children") and data.get("setup") is True:
|
||||
data = data.copy()
|
||||
data["setup"] = False
|
||||
# 已有序列化子资源,跳过 setup 避免重复创建
|
||||
result = super(BIOYOND_PeptideStation_Deck, cls).deserialize(data, allow_marshal=allow_marshal)
|
||||
else:
|
||||
result = super(BIOYOND_PeptideStation_Deck, cls).deserialize(data, allow_marshal=allow_marshal)
|
||||
result._ensure_peptide_warehouse_metadata()
|
||||
return result
|
||||
|
||||
def _ensure_peptide_warehouse_metadata(self) -> None:
|
||||
for child in getattr(self, "children", []):
|
||||
name = getattr(child, "name", "")
|
||||
axis = self.WAREHOUSE_BIOYOND_AXIS.get(name)
|
||||
if axis and not hasattr(child, "bioyond_axis"):
|
||||
child.bioyond_axis = axis
|
||||
key_axis = self.WAREHOUSE_BIOYOND_KEY_AXIS.get(name)
|
||||
if key_axis and not hasattr(child, "bioyond_key_axis"):
|
||||
child.bioyond_key_axis = key_axis
|
||||
|
||||
def _frontend_y_flipped_coordinate(self, display_x: float, display_y: float, child) -> Coordinate:
|
||||
"""把期望显示坐标转换为兼容前端 y 轴翻转的存储坐标。"""
|
||||
return Coordinate(display_x, self.get_size_y() - display_y - child.get_size_y(), 0.0)
|
||||
|
||||
def setup(self) -> None:
|
||||
# 多肽工作站仓库配置
|
||||
# 基于 2026-05-09 live API probe 发现的实际仓库拓扑 (12个仓库)
|
||||
# 数据来源: Bioyond 现场仓库发现结果。
|
||||
self.warehouses = {
|
||||
# 主自动化堆栈 - live API: code 10-17 -> x=17, y=10,显示为 17 行×10 列
|
||||
"自动化堆栈": bioyond_warehouse_numeric_stack(
|
||||
"自动化堆栈",
|
||||
rows=17,
|
||||
columns=10,
|
||||
bioyond_axis="xy_col_row",
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
|
||||
# 低温存储
|
||||
"低温冰箱仓库": bioyond_warehouse_live_grid(
|
||||
"低温冰箱仓库",
|
||||
rows=3,
|
||||
columns=2,
|
||||
slot_keys=["1", "2", "3", "4", "5", "6"],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
|
||||
# 移液站库位
|
||||
"Tecan移液站库": bioyond_warehouse_live_grid(
|
||||
"Tecan移液站库",
|
||||
rows=18,
|
||||
columns=1,
|
||||
slot_keys=[str(index) for index in range(1, 19)],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
"G3移液站库": bioyond_warehouse_live_grid(
|
||||
"G3移液站库",
|
||||
rows=18,
|
||||
columns=1,
|
||||
slot_keys=["1", "2", "3", "4", "垃圾桶", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18"],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
"IDOT移液站库": bioyond_warehouse_live_grid(
|
||||
"IDOT移液站库",
|
||||
rows=12,
|
||||
columns=1,
|
||||
slot_keys=[f"0009-{index:04d}" for index in range(1, 13)],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
|
||||
# 缓冲库位
|
||||
"G3缓冲库": bioyond_warehouse_live_grid(
|
||||
"G3缓冲库",
|
||||
rows=5,
|
||||
columns=1,
|
||||
slot_keys=[str(index) for index in range(1, 6)],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
"盖板缓冲库": bioyond_warehouse_live_grid(
|
||||
"盖板缓冲库",
|
||||
rows=7,
|
||||
columns=1,
|
||||
slot_keys=[str(index) for index in range(1, 8)],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
"配平板缓冲库": bioyond_warehouse_live_grid(
|
||||
"配平板缓冲库",
|
||||
rows=3,
|
||||
columns=1,
|
||||
slot_keys=[str(index) for index in range(1, 4)],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
"IDOT缓冲库": bioyond_warehouse_live_grid(
|
||||
"IDOT缓冲库",
|
||||
rows=2,
|
||||
columns=1,
|
||||
slot_keys=["1", "1"],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
"固相合成板底座缓冲位": bioyond_warehouse_live_grid(
|
||||
"固相合成板底座缓冲位",
|
||||
rows=4,
|
||||
columns=1,
|
||||
slot_keys=[f"0015-{index:04d}" for index in range(1, 5)],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
|
||||
# 设备库位
|
||||
"离心机库位": bioyond_warehouse_live_grid(
|
||||
"离心机库位",
|
||||
rows=4,
|
||||
columns=1,
|
||||
slot_keys=[f"0017-{index:04d}" for index in range(1, 5)],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
"热封膜机位": bioyond_warehouse_live_grid(
|
||||
"热封膜机位",
|
||||
rows=2,
|
||||
columns=1,
|
||||
slot_keys=[f"0016-{index:04d}" for index in range(1, 3)],
|
||||
bioyond_key_axis="col_row",
|
||||
frontend_y_flip=True,
|
||||
),
|
||||
}
|
||||
|
||||
# 仓库显示布局:紧凑排列;存储 y 坐标按前端兼容翻转预先反向。
|
||||
display_layout = {
|
||||
"自动化堆栈": (0.0, 0.0),
|
||||
"Tecan移液站库": (1520.0, 0.0),
|
||||
"G3移液站库": (1710.0, 0.0),
|
||||
"IDOT移液站库": (1900.0, 0.0),
|
||||
"G3缓冲库": (2090.0, 0.0),
|
||||
"盖板缓冲库": (2090.0, 580.0),
|
||||
"低温冰箱仓库": (2280.0, 0.0),
|
||||
"配平板缓冲库": (2280.0, 370.0),
|
||||
"IDOT缓冲库": (2470.0, 370.0),
|
||||
"固相合成板底座缓冲位": (2280.0, 740.0),
|
||||
"离心机库位": (2470.0, 740.0),
|
||||
"热封膜机位": (2280.0, 1210.0),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
name: self._frontend_y_flipped_coordinate(x, y, self.warehouses[name])
|
||||
for name, (x, y) in display_layout.items()
|
||||
}
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
def YB_Deck(name: str) -> Deck:
|
||||
by=BIOYOND_YB_Deck(name=name)
|
||||
by.setup()
|
||||
return by
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
247
unilabos/resources/bioyond/peptide_materials.py
Normal file
247
unilabos/resources/bioyond/peptide_materials.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Peptide Station Material Resource Definitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
try:
|
||||
from pylabrobot.resources import Container, Plate, TipRack
|
||||
except Exception: # pragma: no cover - 允许无 pylabrobot 的轻量动作测试导入
|
||||
class _FallbackResource:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
class Container(_FallbackResource): # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
class Plate(_FallbackResource): # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
class TipRack(_FallbackResource): # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
try:
|
||||
from unilabos.registry.decorators import resource
|
||||
except Exception: # pragma: no cover - 允许无完整 registry 依赖时导入常量
|
||||
def resource(*args, **kwargs):
|
||||
def decorator(cls):
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _ensure_itemized_ordering(kwargs: dict) -> None:
|
||||
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
|
||||
kwargs["ordering"] = OrderedDict()
|
||||
|
||||
|
||||
class _PeptideTipRack(TipRack):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("size_x", 127.76)
|
||||
kwargs.setdefault("size_y", 85.48)
|
||||
kwargs.setdefault("size_z", 64.0)
|
||||
kwargs.setdefault("with_tips", True)
|
||||
_ensure_itemized_ordering(kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class _PeptidePlate(Plate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("size_x", 127.76)
|
||||
kwargs.setdefault("size_y", 85.48)
|
||||
kwargs.setdefault("size_z", 14.35)
|
||||
kwargs.setdefault("plate_type", "skirted")
|
||||
_ensure_itemized_ordering(kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_1000ul_tip_rack",
|
||||
category=["labware", "tip_rack"],
|
||||
description="1000uL tip rack for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_1000ul_TipRack(_PeptideTipRack):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_1000ul_tip_rack")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_200ul_tip_rack",
|
||||
category=["labware", "tip_rack"],
|
||||
description="200uL tip rack for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_200ul_TipRack(_PeptideTipRack):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_200ul_tip_rack")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_50ul_tip_rack",
|
||||
category=["labware", "tip_rack"],
|
||||
description="50uL tip rack for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_50ul_TipRack(_PeptideTipRack):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_50ul_tip_rack")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_96_well_deep_well_plate",
|
||||
category=["labware", "plate"],
|
||||
description="96 well deep well plate for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_96WellDeepWellPlate(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_96_well_deep_well_plate")
|
||||
kwargs.setdefault("size_z", 44.0)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_96_well_synthesis_plate",
|
||||
category=["labware", "plate"],
|
||||
description="96 well solid-phase synthesis plate for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_96WellSynthesisPlate(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_96_well_synthesis_plate")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_96_well_synthesis_plate_base",
|
||||
category=["labware", "adapter"],
|
||||
description="96 well solid-phase synthesis plate base for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_96WellSynthesisPlateBase(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_96_well_synthesis_plate_base")
|
||||
kwargs.setdefault("size_z", 20.0)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_96_well_balance_plate",
|
||||
category=["labware", "plate"],
|
||||
description="96 well balance plate for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_96WellBalancePlate(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_96_well_balance_plate")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_384_well_plate",
|
||||
category=["labware", "plate"],
|
||||
description="384 well plate for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_384WellPlate(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_384_well_plate")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_384_lcms_plate",
|
||||
category=["labware", "plate"],
|
||||
description="384 well LCMS plate for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_384LCMSPlate(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_384_lcms_plate")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_384_balance_plate",
|
||||
category=["labware", "plate"],
|
||||
description="384 well balance plate for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_384BalancePlate(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_384_balance_plate")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_cover_plate",
|
||||
category=["labware", "cover"],
|
||||
description="Cover plate for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_CoverPlate(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_cover_plate")
|
||||
kwargs.setdefault("size_z", 8.0)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_sealing_base",
|
||||
category=["labware", "adapter"],
|
||||
description="Sealing base for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_SealingBase(_PeptidePlate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("model", "bioyond_peptide_sealing_base")
|
||||
kwargs.setdefault("size_z", 20.0)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_peptide_reagent_trough",
|
||||
category=["labware", "trough"],
|
||||
description="Reagent trough for Bioyond peptide station",
|
||||
)
|
||||
class BioyondPeptide_ReagentTrough(Container):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("size_x", 127.76)
|
||||
kwargs.setdefault("size_y", 85.48)
|
||||
kwargs.setdefault("size_z", 44.0)
|
||||
kwargs.setdefault("max_volume", 300000.0)
|
||||
kwargs.setdefault("model", "bioyond_peptide_reagent_trough")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
DEFAULT_PEPTIDE_MATERIAL_TYPE_MAPPINGS = {
|
||||
"bioyond_peptide_1000ul_tip_rack": ["1000μL枪头盒", "3a1890bb-736e-cfdd-3213-eb314e8a60f9"],
|
||||
"bioyond_peptide_200ul_tip_rack": ["200μL枪头盒", "3a1890bb-36d1-964a-18bd-0bf0f2877a7b"],
|
||||
"bioyond_peptide_50ul_tip_rack": ["50μL枪头盒", "3a1890bc-5fae-361c-cc09-e6f2f6dcd71d"],
|
||||
"bioyond_peptide_96_well_deep_well_plate": ["96孔深孔板", "3a1890bc-1fa8-fe39-9faa-12279ed4569b"],
|
||||
"bioyond_peptide_96_well_synthesis_plate": ["96孔固相合成板", "3a1871cb-99f3-f01d-23e2-08dbbd0045b5"],
|
||||
"bioyond_peptide_96_well_synthesis_plate_base": ["96孔固相合成板底座", "3a1b997e-241b-64f0-80d1-47bca08799d1"],
|
||||
"bioyond_peptide_96_well_balance_plate": ["96孔配平板", "3a187661-2378-1e20-fa5c-a27d49fdc15d"],
|
||||
"bioyond_peptide_384_well_plate": ["384孔酶标板", "3a1890bf-2148-ed20-92bd-d85869947d9a"],
|
||||
"bioyond_peptide_384_lcms_plate": ["384孔LCMS板", "3a1e6a8b-cb61-74da-a089-8e6f197f80f0"],
|
||||
"bioyond_peptide_384_balance_plate": ["384孔配平板", "3a18be7e-47cc-888c-fc68-055753286826"],
|
||||
"bioyond_peptide_cover_plate": ["防挥发盖板", "3a19d5a6-b0e2-b486-e5eb-bcabc632f4de"],
|
||||
"bioyond_peptide_sealing_base": ["封膜底座", "3a1d1d7b-e33b-6975-165d-c56cba5ed345"],
|
||||
"bioyond_peptide_reagent_trough": ["12道试剂槽", "3a18b431-ac58-ca2e-9680-2a4f5880ea45"],
|
||||
}
|
||||
|
||||
|
||||
MATERIAL_TYPE_CODE_TO_CLASS = {
|
||||
"0001": BioyondPeptide_96WellSynthesisPlate,
|
||||
"0002": BioyondPeptide_96WellBalancePlate,
|
||||
"0008": BioyondPeptide_200ul_TipRack,
|
||||
"0009": BioyondPeptide_1000ul_TipRack,
|
||||
"0011": BioyondPeptide_96WellDeepWellPlate,
|
||||
"0012": BioyondPeptide_50ul_TipRack,
|
||||
"0016": BioyondPeptide_384WellPlate,
|
||||
"0018": BioyondPeptide_384WellPlate,
|
||||
"0024": BioyondPeptide_ReagentTrough,
|
||||
"0026": BioyondPeptide_384BalancePlate,
|
||||
"0035": BioyondPeptide_CoverPlate,
|
||||
"0039": BioyondPeptide_96WellSynthesisPlateBase,
|
||||
"0041": BioyondPeptide_SealingBase,
|
||||
"0049": BioyondPeptide_384LCMSPlate,
|
||||
}
|
||||
|
||||
|
||||
def get_material_class_by_type_code(type_code: str):
|
||||
"""Return a peptide material class by Bioyond material type code."""
|
||||
return MATERIAL_TYPE_CODE_TO_CLASS.get(type_code)
|
||||
@@ -1,5 +1,192 @@
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources.carrier import ResourceHolder, create_homogeneous_resources
|
||||
|
||||
from unilabos.resources.warehouse import WareHouse, warehouse_factory
|
||||
|
||||
|
||||
class BioyondWareHouse(WareHouse):
|
||||
"""Bioyond 仓库,额外保存服务端 x/y 坐标和库位标签语义。"""
|
||||
|
||||
def __init__(self, *args, bioyond_axis: str = "xy_row_col", bioyond_key_axis: str = "row_col", **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.bioyond_axis = bioyond_axis
|
||||
self.bioyond_key_axis = bioyond_key_axis
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
data["bioyond_axis"] = self.bioyond_axis
|
||||
data["bioyond_key_axis"] = self.bioyond_key_axis
|
||||
return data
|
||||
|
||||
|
||||
def bioyond_warehouse_numeric_stack(
|
||||
name: str,
|
||||
rows: int = 10,
|
||||
columns: int = 17,
|
||||
bioyond_axis: str = "xy_row_col",
|
||||
bioyond_key_axis: str = "row_col",
|
||||
frontend_y_flip: bool = False,
|
||||
) -> WareHouse:
|
||||
"""创建 Bioyond 数字库位堆栈,库位名使用服务端返回的 行-列 格式。
|
||||
|
||||
bioyond_axis: 仓库级别的 Bioyond 坐标轴约定,供 graphio 的坐标映射使用。
|
||||
- "xy_row_col" (default): Bioyond x→row, y→col (reaction/peptide 历史约定).
|
||||
- "xy_col_row": Bioyond x→col, y→row (Sirna live API 实测约定).
|
||||
bioyond_key_axis: 库位标签生成约定。
|
||||
- "row_col" (default): 视觉行列和标签行列一致,例如 10 行 x 17 列 → 1-1..10-17。
|
||||
- "col_row": 视觉行列转置,但标签仍保持 Bioyond row-col,例如
|
||||
17 行 x 10 列 → 1-1..10-17。
|
||||
未设置时 graphio 回退到默认 "xy_row_col",其他调用方保持原行为。
|
||||
"""
|
||||
num_items_x = columns
|
||||
num_items_y = rows
|
||||
num_items_z = 1
|
||||
dx = 10.0
|
||||
dy = 10.0
|
||||
dz = 10.0
|
||||
item_dx = 147.0
|
||||
item_dy = 106.0
|
||||
item_dz = 130.0
|
||||
resource_size_x = 127.0
|
||||
resource_size_y = 86.0
|
||||
resource_size_z = 25.0
|
||||
size_y = dy + item_dy * num_items_y
|
||||
locations = []
|
||||
for row in range(num_items_y):
|
||||
display_y = dy + row * item_dy
|
||||
y = size_y - display_y - resource_size_y if frontend_y_flip else display_y
|
||||
for col in range(num_items_x):
|
||||
locations.append(Coordinate(dx + col * item_dx, y, dz))
|
||||
holders = create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=locations,
|
||||
resource_size_x=resource_size_x,
|
||||
resource_size_y=resource_size_y,
|
||||
resource_size_z=resource_size_z,
|
||||
name_prefix=name,
|
||||
)
|
||||
if bioyond_key_axis == "row_col":
|
||||
keys = [
|
||||
f"{row + 1}-{col + 1}"
|
||||
for row in range(num_items_y)
|
||||
for col in range(num_items_x)
|
||||
]
|
||||
elif bioyond_key_axis == "col_row":
|
||||
keys = [
|
||||
f"{col + 1}-{row + 1}"
|
||||
for row in range(num_items_y)
|
||||
for col in range(num_items_x)
|
||||
]
|
||||
else:
|
||||
raise ValueError(f"未知 Bioyond 库位标签约定: {bioyond_key_axis!r}")
|
||||
warehouse = BioyondWareHouse(
|
||||
name=name,
|
||||
size_x=dx + item_dx * num_items_x,
|
||||
size_y=size_y,
|
||||
size_z=dz + item_dz * num_items_z,
|
||||
num_items_x=num_items_x,
|
||||
num_items_y=num_items_y,
|
||||
num_items_z=num_items_z,
|
||||
ordering_layout="row-major",
|
||||
sites={key: holder for key, holder in zip(keys, holders.values())},
|
||||
category="warehouse",
|
||||
bioyond_axis=bioyond_axis,
|
||||
bioyond_key_axis=bioyond_key_axis,
|
||||
)
|
||||
return warehouse
|
||||
|
||||
|
||||
def bioyond_warehouse_live_grid(
|
||||
name: str,
|
||||
rows: int,
|
||||
columns: int,
|
||||
slot_keys: list[str] | None = None,
|
||||
bioyond_axis: str = "xy_col_row",
|
||||
bioyond_key_axis: str = "row_col",
|
||||
frontend_y_flip: bool = False,
|
||||
) -> WareHouse:
|
||||
"""创建 Bioyond 实测库位网格,按服务端 code 保存位点标签。
|
||||
|
||||
默认用于 Peptide live API 返回的坐标:x 是视觉列,y 是视觉行。
|
||||
当服务端 code 重复时,为保持 PLR ordering 唯一性,会给后续重复项追加 ``#N``。
|
||||
"""
|
||||
num_items_x = columns
|
||||
num_items_y = rows
|
||||
num_items_z = 1
|
||||
dx = 10.0
|
||||
dy = 10.0
|
||||
dz = 10.0
|
||||
item_dx = 147.0
|
||||
item_dy = 106.0
|
||||
item_dz = 130.0
|
||||
resource_size_x = 127.0
|
||||
resource_size_y = 86.0
|
||||
resource_size_z = 25.0
|
||||
size_y = dy + item_dy * num_items_y
|
||||
locations = []
|
||||
for row in range(num_items_y):
|
||||
display_y = dy + row * item_dy
|
||||
y = size_y - display_y - resource_size_y if frontend_y_flip else display_y
|
||||
for col in range(num_items_x):
|
||||
locations.append(Coordinate(dx + col * item_dx, y, dz))
|
||||
holders = create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=locations,
|
||||
resource_size_x=resource_size_x,
|
||||
resource_size_y=resource_size_y,
|
||||
resource_size_z=resource_size_z,
|
||||
name_prefix=name,
|
||||
)
|
||||
keys = slot_keys or [str(index + 1) for index in range(num_items_x * num_items_y)]
|
||||
if len(keys) != len(holders):
|
||||
raise ValueError(f"{name} 库位数量不匹配: keys={len(keys)}, holders={len(holders)}")
|
||||
|
||||
seen: dict[str, int] = {}
|
||||
unique_keys: list[str] = []
|
||||
for key in keys:
|
||||
count = seen.get(key, 0) + 1
|
||||
seen[key] = count
|
||||
unique_keys.append(key if count == 1 else f"{key}#{count}")
|
||||
|
||||
return BioyondWareHouse(
|
||||
name=name,
|
||||
size_x=dx + item_dx * num_items_x,
|
||||
size_y=size_y,
|
||||
size_z=dz + item_dz * num_items_z,
|
||||
num_items_x=num_items_x,
|
||||
num_items_y=num_items_y,
|
||||
num_items_z=num_items_z,
|
||||
ordering_layout="row-major",
|
||||
sites={key: holder for key, holder in zip(unique_keys, holders.values())},
|
||||
category="warehouse",
|
||||
bioyond_axis=bioyond_axis,
|
||||
bioyond_key_axis=bioyond_key_axis,
|
||||
)
|
||||
|
||||
|
||||
# ================ 小核酸工作站相关堆栈 ================
|
||||
|
||||
def bioyond_warehouse_sirna_g3_liquid_handler(name: str = "G3移液站") -> WareHouse:
|
||||
"""创建小核酸 G3 移液站库位堆栈:显示为 14 行 x 1 列,标签保持 1-1..1-14。"""
|
||||
return bioyond_warehouse_numeric_stack(
|
||||
name, rows=14, columns=1, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
|
||||
)
|
||||
|
||||
|
||||
def bioyond_warehouse_sirna_automation_stack(name: str = "自动化堆栈") -> WareHouse:
|
||||
"""创建小核酸自动化堆栈:显示为 17 行 x 10 列,标签保持 1-1..10-17。"""
|
||||
return bioyond_warehouse_numeric_stack(
|
||||
name, rows=17, columns=10, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
|
||||
)
|
||||
|
||||
|
||||
def bioyond_warehouse_sirna_centrifuge_balance_plate_stack(name: str = "离心机配平板堆栈") -> WareHouse:
|
||||
"""创建小核酸离心机配平板堆栈:显示为 1 行 x 2 列,标签保持 1-1、2-1。"""
|
||||
return bioyond_warehouse_numeric_stack(
|
||||
name, rows=1, columns=2, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
|
||||
)
|
||||
|
||||
|
||||
# ================ 反应站相关堆栈 ================
|
||||
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
|
||||
@@ -736,7 +736,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}")
|
||||
continue
|
||||
|
||||
plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
|
||||
plr_material.code = material.get("barCode") or material.get("code") or ""
|
||||
plr_material.unilabos_uuid = str(uuid.uuid4())
|
||||
|
||||
# ⭐ 保存 Bioyond 原始信息到 unilabos_extra(用于出库时查询)
|
||||
@@ -864,11 +864,22 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
warehouse = deck.warehouses[wh_name]
|
||||
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
|
||||
|
||||
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
|
||||
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
|
||||
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
|
||||
# Bioyond坐标映射:
|
||||
# - 历史 row_col 仓库中 x/y 直接按行/列参与索引。
|
||||
# - Sirna 的库位标签为 col-row,stock-material 返回 x=标签第二段、y=标签第一段。
|
||||
# 因此 x=13,y=4 应落到 key=4-13,而不是交换后落到 3-5。
|
||||
x = loc.get("x", 1)
|
||||
y = loc.get("y", 1)
|
||||
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
|
||||
|
||||
# 仓库级别的轴约定覆盖。
|
||||
# 对旧的 row-col 视觉标签,bioyond_axis="xy_col_row" 需要交换 x/y。
|
||||
# 对 Sirna 的 col-row 库位标签,原始 x/y 已能直接索引到 code 对应位置,不再交换。
|
||||
bioyond_axis = getattr(warehouse, "bioyond_axis", "xy_row_col")
|
||||
bioyond_key_axis = getattr(warehouse, "bioyond_key_axis", "row_col")
|
||||
if bioyond_axis == "xy_col_row" and bioyond_key_axis != "col_row":
|
||||
x, y = y, x
|
||||
|
||||
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
|
||||
if wh_name == "堆栈1右":
|
||||
y = y - 4 # 将5-8映射到1-4
|
||||
@@ -912,10 +923,43 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}")
|
||||
|
||||
if 0 <= idx < warehouse.capacity:
|
||||
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
|
||||
slot_key = None
|
||||
ordering = getattr(warehouse, "_ordering", {})
|
||||
sites = getattr(warehouse, "sites", [])
|
||||
if isinstance(ordering, dict) and idx < len(sites):
|
||||
site_at_idx = sites[idx]
|
||||
slot_key = next(
|
||||
(key for key, site in ordering.items() if site is site_at_idx),
|
||||
None,
|
||||
)
|
||||
|
||||
current_resource = warehouse[idx]
|
||||
if current_resource is None or isinstance(current_resource, (ResourceHolder, str)):
|
||||
if isinstance(current_resource, str):
|
||||
logger.warning(
|
||||
f"⚠️ 物料 {unique_name} 覆盖 {wh_name}[{idx}]"
|
||||
f"{f'({slot_key})' if slot_key else ''} 的旧占位 occupied_by={current_resource!r}"
|
||||
)
|
||||
# 物料尺寸已在放入warehouse前根据需要进行了交换
|
||||
warehouse[idx] = plr_material
|
||||
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
|
||||
logger.debug(
|
||||
f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}]"
|
||||
f"{f'({slot_key})' if slot_key else ''} "
|
||||
f"(Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})"
|
||||
)
|
||||
else:
|
||||
parent = getattr(current_resource, "parent", None)
|
||||
current_repr = repr(current_resource)
|
||||
current_len = len(current_resource) if isinstance(current_resource, str) else None
|
||||
logger.warning(
|
||||
f"⚠️ 物料 {unique_name} 跳过放置到 {wh_name}[{idx}]"
|
||||
f"{f'({slot_key})' if slot_key else ''}:目标库位已有 "
|
||||
f"{type(current_resource).__name__}"
|
||||
f"(value={current_repr}, len={current_len})"
|
||||
f"(name={getattr(current_resource, 'name', None)}, "
|
||||
f"parent={getattr(parent, 'name', None)}, "
|
||||
f"uuid={getattr(current_resource, 'unilabos_uuid', None)})"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}")
|
||||
else:
|
||||
@@ -1033,7 +1077,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:
|
||||
# 如果有液体,使用液体的名称
|
||||
@@ -1051,7 +1095,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,
|
||||
@@ -1124,7 +1168,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 实际要求的字段(必需)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,3 +18,7 @@ def register():
|
||||
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_SirnaStation_Deck
|
||||
# noinspection PyUnresolvedReferences
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PeptideStation_Deck
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
# from nt import device_encoding
|
||||
import threading
|
||||
@@ -61,7 +62,7 @@ def main(
|
||||
rclpy.init(args=rclpy_init_args)
|
||||
else:
|
||||
logger.info("[ROS] rclpy already initialized, reusing context")
|
||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||
executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
|
||||
# 创建主机节点
|
||||
host_node = HostNode(
|
||||
"host_node",
|
||||
@@ -122,7 +123,7 @@ def slave(
|
||||
rclpy.init(args=rclpy_init_args)
|
||||
executor = rclpy.__executor
|
||||
if not executor:
|
||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||
executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
|
||||
|
||||
# 1.5 启动 executor 线程
|
||||
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")
|
||||
|
||||
@@ -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)
|
||||
@@ -486,18 +584,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if len(rts.root_nodes) == 1 and parent_resource is not None:
|
||||
plr_instance = plr_instances[0]
|
||||
if isinstance(plr_instance, Plate):
|
||||
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
|
||||
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
|
||||
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
|
||||
self.lab_logger().warning(
|
||||
f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个"
|
||||
)
|
||||
for liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||
):
|
||||
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
|
||||
plr_instance.set_well_liquids(empty_liquid_info_in)
|
||||
try:
|
||||
# noinspection PyProtectedMember
|
||||
keys = list(plr_instance._ordering.keys())
|
||||
@@ -511,6 +603,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
input_wells = []
|
||||
for r in LIQUID_INPUT_SLOT:
|
||||
input_wells.append(plr_instance.children[r])
|
||||
for input_well, liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||
input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||
):
|
||||
input_well.set_liquids([(liquid_type, liquid_volume, "ul")])
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
|
||||
input_wells
|
||||
).dump()
|
||||
@@ -529,12 +625,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
Coordinate(location["x"], location["y"], location["z"]),
|
||||
**other_calling_param,
|
||||
)
|
||||
# 调整了液体以及Deck之后要重新Assign
|
||||
# noinspection PyUnresolvedReferences
|
||||
# _t3 = time.time()
|
||||
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
|
||||
# _n_parent = len(rts_with_parent.all_nodes)
|
||||
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
|
||||
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
|
||||
request.command = json.dumps(
|
||||
request.command = _fast_dumps_str(
|
||||
{
|
||||
"action": "add",
|
||||
"data": {
|
||||
@@ -544,11 +641,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
},
|
||||
}
|
||||
)
|
||||
# _t4 = time.time()
|
||||
# self.lab_logger().debug(
|
||||
# f"[AR:{_ar_tag}] 二次上传序列化 {_n_parent}节点 {((_t4 - _t3) * 1000):.0f}ms, 发送中..."
|
||||
# )
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
# _t5 = time.time()
|
||||
uuid_maps = _fast_loads(tree_response.response)
|
||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||
self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
|
||||
# 这里created_resources不包含parent_resource
|
||||
# self._lab_logger.info(
|
||||
# f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms "
|
||||
# f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms"
|
||||
# )
|
||||
# 发送给ResourceMeshManager
|
||||
action_client = ActionClient(
|
||||
self,
|
||||
@@ -685,7 +789,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
)
|
||||
# 发送请求并等待响应
|
||||
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
|
||||
if not response.response:
|
||||
raise ValueError(f"查询资源 {resource_id} 失败:服务端返回空响应")
|
||||
raw_data = json.loads(response.response)
|
||||
if not raw_data:
|
||||
raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空")
|
||||
|
||||
# 转换为 PLR 资源
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
@@ -1134,7 +1242,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if uid is None:
|
||||
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性,无法转运")
|
||||
target_uids.append(uid)
|
||||
srv_address = f"/srv{target_device_id}/s2c_resource_tree"
|
||||
_ns = target_device_id if target_device_id.startswith("/devices/") else f"/devices/{target_device_id.lstrip('/')}"
|
||||
srv_address = f"/srv{_ns}/s2c_resource_tree"
|
||||
sclient = self.create_client(SerialCommand, srv_address)
|
||||
# 等待服务可用(设置超时)
|
||||
if not sclient.wait_for_service(timeout_sec=5.0):
|
||||
@@ -1184,7 +1293,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
return False
|
||||
time.sleep(0.05)
|
||||
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
|
||||
return None
|
||||
return "转运完成"
|
||||
|
||||
def register_device(self):
|
||||
"""向注册表中注册设备信息"""
|
||||
@@ -1256,9 +1365,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
return self._lab_logger
|
||||
|
||||
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
|
||||
"""创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建。"""
|
||||
# 检测 @topic_config 装饰器配置
|
||||
topic_config = {}
|
||||
"""创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数。"""
|
||||
topic_cfg = {}
|
||||
driver_class = type(self.driver_instance)
|
||||
|
||||
# 区分 @property 和普通方法两种情况
|
||||
@@ -1267,23 +1375,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
)
|
||||
|
||||
if is_prop:
|
||||
# @property: 检测 fget 上的 @topic_config
|
||||
class_attr = getattr(driver_class, attr_name)
|
||||
if class_attr.fget is not None:
|
||||
topic_config = get_topic_config(class_attr.fget)
|
||||
topic_cfg = get_topic_config(class_attr.fget)
|
||||
else:
|
||||
# 普通方法: 直接检测 attr_name 方法上的 @topic_config
|
||||
if hasattr(self.driver_instance, attr_name):
|
||||
method = getattr(self.driver_instance, attr_name)
|
||||
if callable(method):
|
||||
topic_config = get_topic_config(method)
|
||||
|
||||
# 没有 @topic_config 装饰器则跳过发布
|
||||
if not topic_config:
|
||||
return
|
||||
topic_cfg = get_topic_config(method)
|
||||
|
||||
# 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name
|
||||
cfg_name = topic_config.get("name")
|
||||
cfg_name = topic_cfg.get("name")
|
||||
if cfg_name:
|
||||
publish_name = cfg_name
|
||||
elif attr_name.startswith("get_"):
|
||||
@@ -1291,10 +1393,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
else:
|
||||
publish_name = attr_name
|
||||
|
||||
# 使用装饰器配置或默认值
|
||||
cfg_period = topic_config.get("period")
|
||||
cfg_print = topic_config.get("print_publish")
|
||||
cfg_qos = topic_config.get("qos")
|
||||
# @topic_config 参数覆盖默认值
|
||||
cfg_period = topic_cfg.get("period")
|
||||
cfg_print = topic_cfg.get("print_publish")
|
||||
cfg_qos = topic_cfg.get("qos")
|
||||
period: float = cfg_period if cfg_period is not None else initial_period
|
||||
print_publish: bool = cfg_print if cfg_print is not None else self._print_publish
|
||||
qos: int = cfg_qos if cfg_qos is not None else 10
|
||||
@@ -1576,37 +1678,75 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
feedback_msg_types = action_type.Feedback.get_fields_and_field_types()
|
||||
result_msg_types = action_type.Result.get_fields_and_field_types()
|
||||
|
||||
while future is not None and not future.done():
|
||||
if goal_handle.is_cancel_requested:
|
||||
self.lab_logger().info(f"取消动作: {action_name}")
|
||||
future.cancel() # 尝试取消线程池中的任务
|
||||
goal_handle.canceled()
|
||||
return action_type.Result()
|
||||
# 低频 feedback 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} 已取消")
|
||||
@@ -1615,8 +1755,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# self.lab_logger().info(f"动作执行完成: {action_name}")
|
||||
del future
|
||||
|
||||
# 执行失败时跳过物料状态更新
|
||||
if execution_error:
|
||||
execution_success = False
|
||||
|
||||
# 向Host更新物料当前状态
|
||||
if action_name not in ["create_resource_detailed", "create_resource"]:
|
||||
if not execution_error and action_name not in ["create_resource_detailed", "create_resource"]:
|
||||
for k, v in goal.get_fields_and_field_types().items():
|
||||
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
||||
continue
|
||||
@@ -1672,7 +1816,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
for attr_name in result_msg_types.keys():
|
||||
if attr_name in ["success", "reached_goal"]:
|
||||
setattr(result_msg, attr_name, True)
|
||||
setattr(result_msg, attr_name, execution_success)
|
||||
elif attr_name == "return_info":
|
||||
setattr(
|
||||
result_msg,
|
||||
@@ -1778,7 +1922,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
raise ValueError("至少需要提供一个 UUID")
|
||||
|
||||
uuids_list = list(uuids)
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(
|
||||
future: Future = self._resource_clients["c2s_update_resource_tree"].call_async(
|
||||
SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
@@ -1804,6 +1948,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
raise Exception(f"资源查询返回空结果: {uuids_list}")
|
||||
|
||||
raw_data = json.loads(response.response)
|
||||
if not raw_data:
|
||||
raise Exception(f"资源原始查询返回空结果: {raw_data}")
|
||||
|
||||
# 转换为 PLR 资源
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
@@ -1825,10 +1971,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
mapped_plr_resources = []
|
||||
for uuid in uuids_list:
|
||||
found = None
|
||||
for plr_resource in figured_resources:
|
||||
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
|
||||
mapped_plr_resources.append(r)
|
||||
break
|
||||
if r is not None:
|
||||
found = r
|
||||
break
|
||||
if found is None:
|
||||
raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源")
|
||||
mapped_plr_resources.append(found)
|
||||
|
||||
return mapped_plr_resources
|
||||
|
||||
@@ -1921,16 +2072,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
async def _convert_resource_async(self, resource_data: Dict[str, Any]):
|
||||
"""异步转换资源数据为实例"""
|
||||
# 使用封装的get_resource_with_dir方法获取PLR资源
|
||||
plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True)
|
||||
async def _convert_resource_async(self, resource_data: "ResourceDictType"):
|
||||
"""异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询"""
|
||||
unilabos_uuid = resource_data.get("uuid")
|
||||
|
||||
if unilabos_uuid:
|
||||
resource_tree = await self.get_resource([unilabos_uuid], with_children=True)
|
||||
plr_resources = resource_tree.to_plr_resources()
|
||||
if plr_resources:
|
||||
plr_resource = plr_resources[0]
|
||||
else:
|
||||
raise ValueError(f"通过 uuid={unilabos_uuid} 查询资源为空")
|
||||
else:
|
||||
res_id = resource_data.get("id") or resource_data.get("name", "")
|
||||
if not res_id:
|
||||
raise ValueError(f"资源数据缺少 uuid 和 id: {list(resource_data.keys())}")
|
||||
plr_resource = await self.get_resource_with_dir(resource_id=res_id, with_children=True)
|
||||
|
||||
# 通过资源跟踪器获取本地实例
|
||||
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
||||
if len(res) == 0:
|
||||
# todo: 后续通过decoration来区分,减少warning
|
||||
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
|
||||
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例")
|
||||
return plr_resource
|
||||
elif len(res) == 1:
|
||||
return res[0]
|
||||
|
||||
@@ -4,6 +4,8 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
|
||||
|
||||
@@ -618,22 +620,17 @@ class HostNode(BaseROS2DeviceNode):
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
response: List[str] = await self.create_resource_detailed(
|
||||
resources, device_ids, bind_parent_id, bind_location, other_calling_param
|
||||
)
|
||||
|
||||
try:
|
||||
assert len(response) == 1, "Create Resource应当只返回一个结果"
|
||||
for i in response:
|
||||
res = json.loads(i)
|
||||
if "suc" in res:
|
||||
raise ValueError(res.get("error"))
|
||||
return res
|
||||
except Exception as ex:
|
||||
pass
|
||||
_n = "\n"
|
||||
raise ValueError(f"创建资源时失败!\n{_n.join(response)}")
|
||||
assert len(response) == 1, "Create Resource应当只返回一个结果"
|
||||
for i in response:
|
||||
res = json.loads(i)
|
||||
if "suc" in res and not res["suc"]:
|
||||
raise ValueError(res.get("error", "未知错误"))
|
||||
return res
|
||||
raise ValueError(f"创建资源时失败!响应为空")
|
||||
|
||||
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
|
||||
"""
|
||||
@@ -1168,7 +1165,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
else:
|
||||
physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {}))
|
||||
|
||||
response.response = json.dumps(uuid_mapping) if success else "FAILED"
|
||||
response.response = _fast_dumps_str(uuid_mapping) if success else "FAILED"
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
||||
|
||||
async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
|
||||
@@ -1178,6 +1175,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
resource_response = http_client.resource_tree_get(uuid_list, with_children)
|
||||
response.response = json.dumps(resource_response)
|
||||
self.lab_logger().trace(f"[Host Node-Resource] Resource tree get request callback {response.response}")
|
||||
|
||||
async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
|
||||
"""
|
||||
@@ -1230,9 +1228,26 @@ class HostNode(BaseROS2DeviceNode):
|
||||
"""
|
||||
try:
|
||||
# 解析请求数据
|
||||
data = json.loads(request.command)
|
||||
data = _fast_loads(request.command)
|
||||
action = data["action"]
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received")
|
||||
inner = data.get("data", {})
|
||||
if action == "add":
|
||||
mount_uuid = inner.get("mount_uuid", "?")[:8] if isinstance(inner, dict) else "?"
|
||||
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
|
||||
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
|
||||
source = f"mount={mount_uuid}.. nodes≈{node_count}"
|
||||
elif action in ("get", "remove"):
|
||||
uid_list = inner.get("data", inner) if isinstance(inner, dict) else inner
|
||||
source = f"uuids={len(uid_list) if isinstance(uid_list, list) else '?'}"
|
||||
elif action == "update":
|
||||
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
|
||||
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
|
||||
source = f"nodes≈{node_count}"
|
||||
else:
|
||||
source = ""
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] Resource tree {action} request received ({source})"
|
||||
)
|
||||
data = data["data"]
|
||||
if action == "add":
|
||||
await self._resource_tree_action_add_callback(data, response)
|
||||
@@ -1632,6 +1647,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict:
|
||||
"""
|
||||
timeout_seconds: 超时时间(秒),默认3600秒
|
||||
修改的结果无效,是只读的
|
||||
"""
|
||||
return kwargs
|
||||
|
||||
|
||||
@@ -22,6 +22,447 @@
|
||||
"arm_state": "idle",
|
||||
"message": "工作台就绪"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PRCXI",
|
||||
"name": "PRCXI",
|
||||
"type": "device",
|
||||
"class": "liquid_handler.prcxi",
|
||||
"parent": "",
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 562,
|
||||
"height": 394,
|
||||
"depth": 0
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"axis": "Left",
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||
"_resource_child_name": "PRCXI_Deck"
|
||||
},
|
||||
"host": "10.20.30.184",
|
||||
"port": 9999,
|
||||
"debug": true,
|
||||
"setup": true,
|
||||
"is_9320": true,
|
||||
"timeout": 10,
|
||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||
"simulator": true,
|
||||
"channel_num": 2
|
||||
},
|
||||
"data": {
|
||||
"reset_ok": true
|
||||
},
|
||||
"schema": {},
|
||||
"description": "",
|
||||
"model": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 240,
|
||||
"z": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PRCXI_Deck",
|
||||
"name": "PRCXI_Deck",
|
||||
"children": [],
|
||||
"parent": "PRCXI",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 542,
|
||||
"size_y": 374,
|
||||
"size_z": 0,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "deck",
|
||||
"barcode": null,
|
||||
"preferred_pickup_location": null,
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"container",
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T2",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T3",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T4",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T5",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T6",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T7",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T8",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T9",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T10",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T11",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T12",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T13",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T14",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T15",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T16",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -346,7 +346,7 @@ def refactor_data(
|
||||
"template_name": template_name,
|
||||
"resource_name": resource_name,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"lab_node_type": "ILab",
|
||||
"param": step.get("parameters", step.get("action_args", {})),
|
||||
"footer": f"{template_name}-{resource_name}",
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>unilabos_msgs</name>
|
||||
<version>0.10.19</version>
|
||||
<version>0.11.1</version>
|
||||
<description>ROS2 Messages package for unilabos devices</description>
|
||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||
|
||||
Reference in New Issue
Block a user