mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-23 03:19:55 +00:00
Compare commits
86 Commits
633c8b3d2c
...
v0.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b2bfaf8e | ||
|
|
71107e9552 | ||
|
|
1ad4766221 | ||
|
|
67a74172dc | ||
|
|
ccbf5378dd | ||
|
|
c001f6a151 | ||
|
|
145fcaae65 | ||
|
|
a79c0a88bf | ||
|
|
06b6f0d804 | ||
|
|
b551e69f64 | ||
|
|
5179a7e48e | ||
|
|
3a2d9e9603 | ||
|
|
a277bd2bed | ||
|
|
176de521b4 | ||
|
|
38c5c267af | ||
|
|
2a5ddd611d | ||
|
|
8580b84167 | ||
|
|
3f80349d7d | ||
|
|
024156848e | ||
|
|
8066c200b9 | ||
|
|
266366cc25 | ||
|
|
121c3985cc | ||
|
|
6ca5c72fc6 | ||
|
|
bc8c49ddda | ||
|
|
28f93737ac | ||
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 | ||
|
|
b1cdef9185 | ||
|
|
9854ed8c9c | ||
|
|
52544a2c69 | ||
|
|
5ce433e235 | ||
|
|
c7c14d2332 | ||
|
|
6fdd482649 | ||
|
|
d390236318 | ||
|
|
ed8ee29732 | ||
|
|
ffc583e9d5 | ||
|
|
f1ad0c9c96 | ||
|
|
8fa3407649 | ||
|
|
d3282822fc | ||
|
|
554bcade24 | ||
|
|
a662c75de1 | ||
|
|
931614fe64 | ||
|
|
d39662f65f | ||
|
|
acf5fdebf8 | ||
|
|
7f7b1c13c0 | ||
|
|
75f09034ff | ||
|
|
549a50220b | ||
|
|
4189a2cfbe | ||
|
|
48895a9bb1 | ||
|
|
891f126ed6 | ||
|
|
4d3475a849 | ||
|
|
b475db66df | ||
|
|
a625a86e3e | ||
|
|
37e0f1037c | ||
|
|
a242253145 | ||
|
|
448e0074b7 | ||
|
|
304827fc8d | ||
|
|
872b3d781f | ||
|
|
813400f2b4 | ||
|
|
b6dfe2b944 | ||
|
|
8807865649 | ||
|
|
5fc7eb7586 | ||
|
|
9bd72b48e1 | ||
|
|
42b78ab4c1 | ||
|
|
9645609a05 | ||
|
|
a2a827d7ac | ||
|
|
bb3ca645a4 | ||
|
|
37ee43d19a | ||
|
|
bc30f23e34 | ||
|
|
166d84afe1 | ||
|
|
1b43c53015 | ||
|
|
d4415f5a35 | ||
|
|
0260cbbedb | ||
|
|
7c440d10ab | ||
|
|
c85c49817d | ||
|
|
c70eafa5f0 | ||
|
|
b64466d443 | ||
|
|
ef3f24ed48 | ||
|
|
2a8e8d014b | ||
|
|
e0da1c7217 | ||
|
|
51d3e61723 | ||
|
|
6b5765bbf3 | ||
|
|
eb1f3fbe1c | ||
|
|
fb93b1cd94 | ||
|
|
9aeffebde1 |
@@ -71,22 +71,6 @@ 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
|
||||
@@ -121,27 +105,13 @@ import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.registry.decorators import action, device, not_action, topic_config
|
||||
from unilabos.registry.decorators import device, action, topic_config, not_action
|
||||
|
||||
@device(
|
||||
id="my_device",
|
||||
category=["my_category"],
|
||||
description="设备描述",
|
||||
display_name="设备显示名",
|
||||
)
|
||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
||||
class MyDevice:
|
||||
"""设备类说明。"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
"""
|
||||
初始化设备。
|
||||
|
||||
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}")
|
||||
@@ -163,13 +133,7 @@ class MyDevice:
|
||||
|
||||
@action(description="执行操作")
|
||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
带 @action 装饰器 → 注册为 'my_action' 动作。
|
||||
|
||||
Args:
|
||||
param[操作数值]: 操作使用的数值参数。
|
||||
name[操作名称]: 操作名称或备注。
|
||||
"""
|
||||
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
|
||||
return {"success": True}
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
---
|
||||
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`),无需额外安装。
|
||||
@@ -1,191 +0,0 @@
|
||||
#!/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()
|
||||
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 --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
|
||||
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
|
||||
|
||||
- name: Install pip dependencies and unilabos
|
||||
run: |
|
||||
|
||||
77
.github/workflows/conda-pack-build.yml
vendored
77
.github/workflows/conda-pack-build.yml
vendored
@@ -1,10 +1,6 @@
|
||||
name: Build Conda-Pack Environment
|
||||
|
||||
on:
|
||||
# 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack
|
||||
workflow_run:
|
||||
workflows: ["UniLabOS Conda Build"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
@@ -25,16 +21,6 @@ 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:
|
||||
@@ -68,9 +54,7 @@ jobs:
|
||||
id: should_build
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then
|
||||
if [[ -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
|
||||
@@ -81,7 +65,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref }}
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniforge (with mamba)
|
||||
@@ -91,7 +75,7 @@ jobs:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
python-version: '3.11.14'
|
||||
channels: conda-forge,robostack-staging,uni-lab
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: flexible
|
||||
activate-environment: unilab
|
||||
auto-update-conda: false
|
||||
@@ -102,13 +86,13 @@ jobs:
|
||||
run: |
|
||||
echo Installing unilabos and dependencies to unilab environment...
|
||||
echo Using mamba for faster and more reliable dependency resolution...
|
||||
echo Build full: ${{ env.BUILD_FULL }}
|
||||
if "${{ env.BUILD_FULL }}"=="true" (
|
||||
echo Build full: ${{ github.event.inputs.build_full }}
|
||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
||||
echo Installing unilabos-full ^(complete package^)...
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
|
||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
) else (
|
||||
echo Installing unilabos ^(minimal package^)...
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
)
|
||||
|
||||
- name: Install conda-pack, unilabos and dependencies (Unix)
|
||||
@@ -117,13 +101,13 @@ jobs:
|
||||
run: |
|
||||
echo "Installing unilabos and dependencies to unilab environment..."
|
||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||
echo "Build full: ${{ env.BUILD_FULL }}"
|
||||
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
|
||||
echo "Build full: ${{ github.event.inputs.build_full }}"
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo "Installing unilabos-full (complete package)..."
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
|
||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
else
|
||||
echo "Installing unilabos (minimal package)..."
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
fi
|
||||
|
||||
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
||||
@@ -150,27 +134,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 --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed
|
||||
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed
|
||||
echo.
|
||||
echo Updating ros-humble-unilabos-msgs to 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
|
||||
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -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 --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed"
|
||||
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed"
|
||||
echo ""
|
||||
echo "Updating ros-humble-unilabos-msgs to 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"
|
||||
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -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 (ref: ${{ env.PACKAGE_REF }})...
|
||||
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
|
||||
mamba run -n unilab pip install .
|
||||
echo Verifying installation...
|
||||
mamba run -n unilab pip show unilabos
|
||||
@@ -181,7 +165,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 (ref: ${{ env.PACKAGE_REF }})..."
|
||||
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
|
||||
mamba run -n unilab pip install .
|
||||
echo "Verifying installation..."
|
||||
mamba run -n unilab pip show unilabos
|
||||
@@ -242,9 +226,7 @@ jobs:
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||
run: |
|
||||
echo Packing unilab environment with conda-pack...
|
||||
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
|
||||
mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||
echo Pack file created:
|
||||
dir unilab-env-${{ matrix.platform }}.tar.gz
|
||||
|
||||
@@ -253,9 +235,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Packing unilab environment with conda-pack..."
|
||||
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
|
||||
mamba install conda-pack -c conda-forge -y
|
||||
conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||
echo "Pack file created:"
|
||||
ls -lh unilab-env-${{ matrix.platform }}.tar.gz
|
||||
|
||||
@@ -286,7 +267,7 @@ jobs:
|
||||
|
||||
rem Create README using Python script
|
||||
echo Creating: README.txt
|
||||
python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt
|
||||
python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt
|
||||
|
||||
echo.
|
||||
echo Distribution package contents:
|
||||
@@ -322,7 +303,7 @@ jobs:
|
||||
|
||||
# Create README using Python script
|
||||
echo "Creating: README.txt"
|
||||
python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt
|
||||
python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt
|
||||
|
||||
echo ""
|
||||
echo "Distribution package contents:"
|
||||
@@ -333,7 +314,7 @@ jobs:
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}
|
||||
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||
path: dist-package/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
@@ -345,9 +326,9 @@ jobs:
|
||||
echo Build Summary
|
||||
echo ==========================================
|
||||
echo Platform: ${{ matrix.platform }}
|
||||
echo Branch: ${{ env.PACKAGE_REF }}
|
||||
echo Branch: ${{ github.event.inputs.branch }}
|
||||
echo Python version: 3.11.14
|
||||
if "${{ env.BUILD_FULL }}"=="true" (
|
||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
||||
echo Package: unilabos-full ^(complete^)
|
||||
) else (
|
||||
echo Package: unilabos ^(minimal^)
|
||||
@@ -356,7 +337,7 @@ jobs:
|
||||
echo Distribution package contents:
|
||||
dir dist-package
|
||||
echo.
|
||||
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}
|
||||
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||
echo.
|
||||
echo After download, extract the ZIP and run:
|
||||
echo install_unilab.bat
|
||||
@@ -370,9 +351,9 @@ jobs:
|
||||
echo "Build Summary"
|
||||
echo "=========================================="
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "Branch: ${{ env.PACKAGE_REF }}"
|
||||
echo "Branch: ${{ github.event.inputs.branch }}"
|
||||
echo "Python version: 3.11.14"
|
||||
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo "Package: unilabos-full (complete)"
|
||||
else
|
||||
echo "Package: unilabos (minimal)"
|
||||
@@ -381,7 +362,7 @@ jobs:
|
||||
echo "Distribution package contents:"
|
||||
ls -lh dist-package/
|
||||
echo ""
|
||||
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}"
|
||||
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
|
||||
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
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
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 --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y
|
||||
mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -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,9 +10,6 @@ on:
|
||||
# 支持 tag 推送(不依赖 CI Check)
|
||||
push:
|
||||
tags: ['v*']
|
||||
# GitHub Release 发布时自动构建并上传
|
||||
release:
|
||||
types: [published]
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -83,7 +80,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }}
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if platform should be built
|
||||
@@ -99,13 +96,12 @@ jobs:
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Miniforge
|
||||
- name: Setup Miniconda
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
channels: conda-forge,robostack-staging
|
||||
miniconda-version: 'latest'
|
||||
channels: conda-forge,robostack-staging,defaults
|
||||
channel-priority: strict
|
||||
activate-environment: build-env
|
||||
auto-update-conda: false
|
||||
@@ -114,7 +110,7 @@ jobs:
|
||||
- name: Install rattler-build and anaconda-client
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
|
||||
conda install -c conda-forge rattler-build anaconda-client
|
||||
|
||||
- name: Show environment info
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
@@ -161,13 +157,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload to Anaconda.org (unilab organization)
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
(
|
||||
github.event_name == 'release' ||
|
||||
startsWith(github.ref, 'refs/tags/') ||
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
)
|
||||
if: steps.should_build.outputs.should_build == 'true' && 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,10 +1,14 @@
|
||||
name: UniLabOS Conda Build
|
||||
|
||||
on:
|
||||
# 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发
|
||||
# 在 CI Check 成功后自动触发
|
||||
workflow_run:
|
||||
workflows: ["Multi-Platform Conda Build"]
|
||||
workflows: ["CI Check"]
|
||||
types: [completed]
|
||||
branches: [main, dev]
|
||||
# 标签推送时直接触发(发布版本)
|
||||
push:
|
||||
tags: ['v*']
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -29,30 +33,30 @@ on:
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发)
|
||||
wait-for-upstream:
|
||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run'
|
||||
outputs:
|
||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
||||
steps:
|
||||
- name: Check upstream workflow status
|
||||
- name: Check CI status
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||
echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build"
|
||||
echo "CI Check passed, proceeding with build"
|
||||
else
|
||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||
echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build"
|
||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: [wait-for-upstream]
|
||||
# 运行条件:workflow_run 触发且上游成功,或者手动触发
|
||||
needs: [wait-for-ci]
|
||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
||||
if: |
|
||||
always() &&
|
||||
(needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true')
|
||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -75,7 +79,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# 如果是 workflow_run 触发,使用上游 conda 包构建的 commit
|
||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -92,13 +96,12 @@ jobs:
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Miniforge
|
||||
- name: Setup Miniconda
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
channels: conda-forge,robostack-staging,uni-lab
|
||||
miniconda-version: 'latest'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: strict
|
||||
activate-environment: build-env
|
||||
auto-update-conda: false
|
||||
@@ -107,7 +110,7 @@ jobs:
|
||||
- name: Install rattler-build and anaconda-client
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
|
||||
conda install -c conda-forge rattler-build anaconda-client
|
||||
|
||||
- name: Show environment info
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
@@ -116,11 +119,11 @@ jobs:
|
||||
conda list | grep -E "(rattler-build|anaconda-client)"
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "OS: ${{ matrix.os }}"
|
||||
echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}"
|
||||
echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}"
|
||||
echo "Building packages:"
|
||||
echo " - unilabos-env (environment dependencies)"
|
||||
echo " - unilabos (with pip package)"
|
||||
if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo " - unilabos-full (complete package)"
|
||||
fi
|
||||
|
||||
@@ -131,12 +134,7 @@ 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_name == 'workflow_run' ||
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
)
|
||||
if: steps.should_build.outputs.should_build == 'true' && 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
|
||||
@@ -151,12 +149,7 @@ 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_name == 'workflow_run' ||
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
)
|
||||
if: steps.should_build.outputs.should_build == 'true' && 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
|
||||
@@ -166,7 +159,6 @@ 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 }}..."
|
||||
@@ -175,7 +167,6 @@ 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: |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
channel_sources:
|
||||
- robostack,robostack-staging,conda-forge
|
||||
- robostack,robostack-staging,conda-forge,defaults
|
||||
|
||||
gazebo:
|
||||
- '11'
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
@@ -24,15 +25,7 @@ class SimpleGraph:
|
||||
|
||||
def add_edge(self, source, target, **attrs):
|
||||
"""添加边"""
|
||||
# edge = {"source": source, "target": target, **attrs}
|
||||
edge = {
|
||||
"source": source, "target": target,
|
||||
"source_node_uuid": source,
|
||||
"target_node_uuid": target,
|
||||
"source_handle_io": "source",
|
||||
"target_handle_io": "target",
|
||||
**attrs
|
||||
}
|
||||
edge = {"source": source, "target": target, **attrs}
|
||||
self.edges.append(edge)
|
||||
|
||||
def to_dict(self):
|
||||
@@ -49,7 +42,6 @@ class SimpleGraph:
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
@@ -66,8 +58,495 @@ def extract_json_from_markdown(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||
}
|
||||
|
||||
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
for step in data:
|
||||
operation = step.get("action")
|
||||
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||
continue
|
||||
|
||||
# 处理重复操作
|
||||
if operation == "Repeat":
|
||||
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||
for i in range(int(times)):
|
||||
sub_data = refactor_data(sub_steps)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template = OPERATION_MAPPING.get(operation)
|
||||
if not template:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||
else:
|
||||
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template": template,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||
}
|
||||
refactored_data.append(step_data)
|
||||
|
||||
return refactored_data
|
||||
|
||||
|
||||
def build_protocol_graph(
|
||||
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||
) -> SimpleGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||
G = SimpleGraph()
|
||||
resource_last_writer = {}
|
||||
LAB_NAME = "SynBioFactory"
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps)
|
||||
|
||||
# 检查协议步骤中的模板来判断协议类型
|
||||
has_biomek_template = any(
|
||||
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||
for step in protocol_steps
|
||||
)
|
||||
|
||||
if has_biomek_template:
|
||||
# 生物实验协议图构建
|
||||
for labware_id, labware in labware_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
labware_attrs = labware.copy()
|
||||
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||
labware_attrs["description"] = labware_id
|
||||
labware_attrs["lab_node_type"] = (
|
||||
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||
)
|
||||
labware_attrs["device_id"] = workstation_name
|
||||
|
||||
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||
|
||||
# 处理协议步骤
|
||||
prev_node = None
|
||||
for i, step in enumerate(protocol_steps):
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 添加控制流边
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||
prev_node = node_id
|
||||
|
||||
# 处理物料流
|
||||
params = step.get("parameters", {})
|
||||
if "sources" in params and params["sources"] in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||
|
||||
if "targets" in params:
|
||||
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||
|
||||
# 添加协议结束节点
|
||||
end_id = str(uuid.uuid4())
|
||||
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||
|
||||
else:
|
||||
# 有机化学协议图构建
|
||||
WORKSTATION_ID = workstation_name
|
||||
|
||||
# 为所有labware创建资源节点
|
||||
for item_id, item in labware_info.items():
|
||||
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
# 判断节点类型
|
||||
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||
if "reactor" not in str(item_id).lower():
|
||||
continue
|
||||
lab_node_type = "Sample"
|
||||
description = f"Prepare Reactor: {item_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
else:
|
||||
lab_node_type = "Reagent"
|
||||
description = f"Add Reagent to Flask: {item_id}"
|
||||
liquid_type = [item_id]
|
||||
liquid_volume = [1e5]
|
||||
|
||||
G.add_node(
|
||||
node_id,
|
||||
template=f"{LAB_NAME}-host_node-create_resource",
|
||||
description=description,
|
||||
lab_node_type=lab_node_type,
|
||||
res_id=item_id,
|
||||
device_id=WORKSTATION_ID,
|
||||
class_name="container",
|
||||
parent=WORKSTATION_ID,
|
||||
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
liquid_input_slot=[-1],
|
||||
liquid_type=liquid_type,
|
||||
liquid_volume=liquid_volume,
|
||||
slot_on_deck="",
|
||||
role=item.get("role", ""),
|
||||
)
|
||||
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||
|
||||
last_control_node_id = None
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 控制流
|
||||
if last_control_node_id is not None:
|
||||
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||
last_control_node_id = node_id
|
||||
|
||||
# 物料流
|
||||
params = step.get("parameters", {})
|
||||
input_resources = {
|
||||
"Vessel": params.get("vessel"),
|
||||
"ToVessel": params.get("to_vessel"),
|
||||
"FromVessel": params.get("from_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources": params.get("sources"),
|
||||
"targets": params.get("targets"),
|
||||
}
|
||||
|
||||
for target_port, resource_name in input_resources.items():
|
||||
if resource_name and resource_name in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||
|
||||
output_resources = {
|
||||
"VesselOut": params.get("vessel"),
|
||||
"FromVesselOut": params.get("from_vessel"),
|
||||
"ToVesselOut": params.get("to_vessel"),
|
||||
"FiltrateOut": params.get("filtrate_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources_out": params.get("sources"),
|
||||
"targets_out": params.get("targets"),
|
||||
}
|
||||
|
||||
for source_port, resource_name in output_resources.items():
|
||||
if resource_name:
|
||||
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||
"""
|
||||
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
G.add_node(node_id, label=label, **attrs)
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
G.add_edge(edge["source"], edge["target"])
|
||||
|
||||
plt.figure(figsize=(20, 15))
|
||||
try:
|
||||
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||
except Exception:
|
||||
pos = nx.shell_layout(G) # Fallback layout
|
||||
|
||||
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||
nx.draw(
|
||||
G,
|
||||
pos,
|
||||
with_labels=False,
|
||||
node_size=2500,
|
||||
node_color="skyblue",
|
||||
node_shape="o",
|
||||
edge_color="gray",
|
||||
width=1.5,
|
||||
arrowsize=15,
|
||||
)
|
||||
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||
|
||||
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" - Visualization saved to '{output_path}'")
|
||||
|
||||
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import re
|
||||
|
||||
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||
|
||||
def _is_compass(port: str) -> bool:
|
||||
return isinstance(port, str) and port.lower() in COMPASS
|
||||
|
||||
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||
"""
|
||||
使用 Graphviz 端口语法绘制协议工作流图。
|
||||
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||
G = nx.DiGraph()
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||
|
||||
edges_data = []
|
||||
in_ports_by_node = {} # 收集命名输入端口
|
||||
out_ports_by_node = {} # 收集命名输出端口
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
u = edge["source"]
|
||||
v = edge["target"]
|
||||
sp = edge.get("source_port")
|
||||
tp = edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||
edges_data.append((u, v, sp, tp))
|
||||
|
||||
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||
if sp and not _is_compass(sp):
|
||||
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||
if tp and not _is_compass(tp):
|
||||
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||
|
||||
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||
A = to_agraph(G)
|
||||
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||
|
||||
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||
for n in A.nodes():
|
||||
node = A.get_node(n)
|
||||
core = G.nodes[n].get("_core_label", n)
|
||||
|
||||
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||
|
||||
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||
if in_ports or out_ports:
|
||||
def port_fields(ports):
|
||||
if not ports:
|
||||
return " " # 必须留一个空槽占位
|
||||
# 每个端口一个小格子,<p> name
|
||||
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||
|
||||
left = port_fields(in_ports)
|
||||
right = port_fields(out_ports)
|
||||
|
||||
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||
record_label = f"{{ {left} | {core} | {right} }}"
|
||||
node.attr.update(shape="record", label=record_label)
|
||||
else:
|
||||
# 没有命名端口:普通盒子,显示核心标签
|
||||
node.attr.update(label=str(core))
|
||||
|
||||
# 4) 给边设置 headport / tailport
|
||||
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||
for (u, v, sp, tp) in edges_data:
|
||||
e = A.get_edge(u, v)
|
||||
|
||||
# Graphviz 属性:tail 是源,head 是目标
|
||||
if sp:
|
||||
if _is_compass(sp):
|
||||
e.attr["tailport"] = sp.lower()
|
||||
else:
|
||||
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||
|
||||
if tp:
|
||||
if _is_compass(tp):
|
||||
e.attr["headport"] = tp.lower()
|
||||
else:
|
||||
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||
|
||||
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||
# e.attr["arrowhead"] = "vee"
|
||||
|
||||
# 5) 输出
|
||||
A.draw(output_path, prog="dot")
|
||||
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
|
||||
|
||||
def create_workflow(
|
||||
|
||||
@@ -14,30 +14,20 @@ 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 (
|
||||
ActionInputHandle,
|
||||
ActionOutputHandle,
|
||||
DataSource,
|
||||
NodeType,
|
||||
action,
|
||||
device,
|
||||
not_action,
|
||||
topic_config,
|
||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
|
||||
)
|
||||
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.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
|
||||
@@ -122,7 +112,6 @@ 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",
|
||||
)
|
||||
@@ -148,19 +137,7 @@ 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,
|
||||
):
|
||||
"""
|
||||
初始化虚拟工作台。
|
||||
|
||||
Args:
|
||||
device_id[设备ID]: 工作台设备实例 ID,默认使用 virtual_workbench。
|
||||
config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。
|
||||
"""
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
# 处理可能的不同调用方式
|
||||
if device_id is None and "id" in kwargs:
|
||||
device_id = kwargs.pop("id")
|
||||
@@ -174,13 +151,9 @@ 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()
|
||||
@@ -189,8 +162,7 @@ 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()
|
||||
|
||||
@@ -320,113 +292,45 @@ class VirtualWorkbench:
|
||||
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,
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
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,
|
||||
@@ -439,156 +343,67 @@ class VirtualWorkbench:
|
||||
battery_system: List[str],
|
||||
timeout_seconds: int,
|
||||
assignee_user_ids: list[str],
|
||||
**kwargs,
|
||||
**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:
|
||||
修改的结果无效,是只读的。
|
||||
timeout_seconds: 超时时间(秒),默认3600秒
|
||||
collector_mass: 极流体质量
|
||||
active_material: 活性物质含量
|
||||
capacity: 克容量(mAh/g)
|
||||
battery_system: 电池体系
|
||||
修改的结果无效,是只读的
|
||||
"""
|
||||
resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump()
|
||||
mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump()
|
||||
resource = ResourceTreeSet.from_plr_resources(resource).dump()
|
||||
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
|
||||
kwargs.update(locals())
|
||||
kwargs.pop("kwargs")
|
||||
kwargs.pop("self")
|
||||
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,
|
||||
),
|
||||
],
|
||||
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,
|
||||
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
|
||||
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
|
||||
**{
|
||||
"plr_resources": resource,
|
||||
"target_device_id": target_device,
|
||||
"target_resources": mount_resource,
|
||||
"sites": [None] * len(mount_resource),
|
||||
},
|
||||
)
|
||||
})
|
||||
result = await future
|
||||
return result
|
||||
|
||||
|
||||
@action(
|
||||
description="扣电测试启动",
|
||||
handles=[
|
||||
ActionInputHandle(
|
||||
key="resource",
|
||||
data_type="resource",
|
||||
label="待转移资源",
|
||||
data_key="resource",
|
||||
data_source=DataSource.HANDLE,
|
||||
),
|
||||
ActionInputHandle(
|
||||
key="mount_resource",
|
||||
data_type="resource",
|
||||
label="目标孔位",
|
||||
data_key="mount_resource",
|
||||
data_source=DataSource.HANDLE,
|
||||
),
|
||||
ActionInputHandle(
|
||||
key="collector_mass",
|
||||
data_type="collector_mass",
|
||||
label="极流体质量",
|
||||
data_key="collector_mass",
|
||||
data_source=DataSource.HANDLE,
|
||||
),
|
||||
ActionInputHandle(
|
||||
key="active_material",
|
||||
data_type="active_material",
|
||||
label="活性物质含量",
|
||||
data_key="active_material",
|
||||
data_source=DataSource.HANDLE,
|
||||
),
|
||||
ActionInputHandle(
|
||||
key="capacity",
|
||||
data_type="capacity",
|
||||
label="克容量",
|
||||
data_key="capacity",
|
||||
data_source=DataSource.HANDLE,
|
||||
),
|
||||
ActionInputHandle(
|
||||
key="battery_system",
|
||||
data_type="battery_system",
|
||||
label="电池体系",
|
||||
data_key="battery_system",
|
||||
data_source=DataSource.HANDLE,
|
||||
),
|
||||
],
|
||||
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],
|
||||
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)
|
||||
@@ -600,11 +415,16 @@ class VirtualWorkbench:
|
||||
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), # 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
|
||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def prepare_materials(
|
||||
@@ -617,9 +437,6 @@ class VirtualWorkbench:
|
||||
|
||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
||||
|
||||
Args:
|
||||
count[物料数量]: 要生成的物料数量,默认生成 5 个。
|
||||
"""
|
||||
materials = [i for i in range(1, count + 1)]
|
||||
|
||||
@@ -640,11 +457,7 @@ 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()
|
||||
],
|
||||
@@ -654,27 +467,12 @@ 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(
|
||||
@@ -686,9 +484,6 @@ class VirtualWorkbench:
|
||||
将物料从An位置移动到加热台
|
||||
|
||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
||||
|
||||
Args:
|
||||
material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。
|
||||
"""
|
||||
material_id = f"A{material_number}"
|
||||
task_desc = f"移动{material_id}到加热台"
|
||||
@@ -751,8 +546,7 @@ 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,8 +569,7 @@ 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()
|
||||
@@ -788,34 +581,14 @@ 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(
|
||||
@@ -826,10 +599,6 @@ class VirtualWorkbench:
|
||||
) -> StartHeatingResult:
|
||||
"""
|
||||
启动指定加热台的加热程序
|
||||
|
||||
Args:
|
||||
station_id[加热台ID]: 要启动加热的加热台编号。
|
||||
material_number[物料编号]: 当前加热台上的物料编号。
|
||||
"""
|
||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||
|
||||
@@ -846,8 +615,7 @@ 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()
|
||||
@@ -870,8 +638,7 @@ 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()
|
||||
@@ -891,8 +658,7 @@ 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()
|
||||
@@ -932,9 +698,7 @@ 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:
|
||||
@@ -951,9 +715,7 @@ 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,
|
||||
@@ -967,8 +729,7 @@ 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()
|
||||
@@ -979,20 +740,10 @@ 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(
|
||||
@@ -1003,10 +754,6 @@ class VirtualWorkbench:
|
||||
) -> MoveToOutputResult:
|
||||
"""
|
||||
将物料从加热台移动到输出位置Cn
|
||||
|
||||
Args:
|
||||
station_id[加热台ID]: 已完成加热的加热台编号。
|
||||
material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。
|
||||
"""
|
||||
output_number = material_number
|
||||
|
||||
@@ -1023,8 +770,7 @@ 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()
|
||||
@@ -1048,8 +794,7 @@ 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()
|
||||
@@ -1069,8 +814,7 @@ 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()
|
||||
@@ -1152,8 +896,7 @@ 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()
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
"""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
|
||||
``temp_benyao/peptide/_logs/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)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .sirna_station import BioyondSirnaStation, fetch_workflow_list, load_sirna_config
|
||||
|
||||
__all__ = ["BioyondSirnaStation", "fetch_workflow_list", "load_sirna_config"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ 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
|
||||
@@ -15,7 +14,6 @@ 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
|
||||
@@ -176,8 +174,6 @@ 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,
|
||||
@@ -191,29 +187,6 @@ 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:
|
||||
@@ -705,70 +678,6 @@ 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 "temp_benyao/_logs/bioyond_debug"
|
||||
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,
|
||||
@@ -953,7 +862,7 @@ class BioyondWorkstation(WorkstationBase):
|
||||
self.bioyond_config = {}
|
||||
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||
|
||||
self.hardware_interface = self._create_bioyond_rpc(self.bioyond_config)
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
|
||||
def resource_tree_add(self, resources: List[ResourcePLR]) -> None:
|
||||
"""添加资源到资源树并更新ROS节点
|
||||
@@ -1429,7 +1338,11 @@ 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 = 2 # 缓存格式版本号,格式变更时递增
|
||||
_CACHE_VERSION = 1 # 缓存格式版本号,格式变更时递增
|
||||
|
||||
# 合法的装饰器来源模块
|
||||
_REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators"
|
||||
@@ -258,6 +258,8 @@ def scan_directory(
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-level parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -359,7 +361,6 @@ 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,
|
||||
}
|
||||
@@ -496,6 +497,7 @@ def _collect_imports(tree: ast.Module, module_path: str = "") -> Dict[str, str]:
|
||||
return import_map
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decorator finding & argument extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -766,7 +768,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -779,7 +780,6 @@ 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 ---
|
||||
|
||||
@@ -51,18 +51,14 @@ 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
|
||||
@@ -89,14 +85,11 @@ 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
|
||||
@@ -158,13 +151,6 @@ 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,17 +491,14 @@ 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
|
||||
@@ -530,16 +527,12 @@ 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
|
||||
@@ -668,20 +661,15 @@ 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
|
||||
@@ -1968,19 +1956,19 @@ bioyond_cell:
|
||||
properties:
|
||||
source_wh_id:
|
||||
default: 3a19debc-84b4-0359-e2d4-b3beea49348b
|
||||
description: 来源仓库 Id (默认为3号仓库)
|
||||
description: 来源仓库ID
|
||||
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
|
||||
@@ -2073,11 +2061,9 @@ bioyond_cell:
|
||||
goal:
|
||||
properties:
|
||||
order_code:
|
||||
description: 任务编号
|
||||
type: string
|
||||
timeout:
|
||||
default: 36000
|
||||
description: 超时时间(秒)
|
||||
type: integer
|
||||
required:
|
||||
- order_code
|
||||
@@ -2106,15 +2092,12 @@ 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
|
||||
@@ -2171,15 +2154,10 @@ 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,10 +47,8 @@ bioyond_dispensing_station:
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
description: WorkstationReportRequest 对象,包含任务完成信息
|
||||
type: string
|
||||
used_materials:
|
||||
description: 物料使用记录列表
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
@@ -104,7 +102,6 @@ bioyond_dispensing_station:
|
||||
goal:
|
||||
properties:
|
||||
material_name:
|
||||
description: 物料名称
|
||||
type: string
|
||||
required:
|
||||
- material_name
|
||||
@@ -614,10 +611,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
|
||||
@@ -697,13 +694,10 @@ 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: 负极片矩阵点位
|
||||
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
fujipian_panshu:
|
||||
default: 0
|
||||
@@ -204,7 +204,7 @@ coincellassemblyworkstation_device:
|
||||
type: integer
|
||||
gemo_juzhendianwei:
|
||||
default: 0
|
||||
description: 隔膜矩阵点位
|
||||
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
gemopanshu:
|
||||
default: 0
|
||||
@@ -216,7 +216,7 @@ coincellassemblyworkstation_device:
|
||||
type: boolean
|
||||
qiangtou_juzhendianwei:
|
||||
default: 0
|
||||
description: 枪头盒矩阵点位
|
||||
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
required:
|
||||
- elec_num
|
||||
@@ -308,13 +308,7 @@ coincellassemblyworkstation_device:
|
||||
properties:
|
||||
material_search_enable:
|
||||
default: false
|
||||
description: '是否启用物料搜寻功能。
|
||||
|
||||
设备初始化后会弹出物料搜寻确认弹窗,
|
||||
|
||||
此参数控制自动点击''是''(启用)或''否''(不启用)。
|
||||
|
||||
默认为False(不启用物料搜寻)。'
|
||||
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
@@ -553,15 +547,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
|
||||
@@ -569,29 +563,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: 电解液瓶数
|
||||
description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数
|
||||
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
|
||||
@@ -599,7 +593,7 @@ coincellassemblyworkstation_device:
|
||||
type: string
|
||||
fujipian_juzhendianwei:
|
||||
default: 0
|
||||
description: 负极片矩阵点位
|
||||
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
fujipian_panshu:
|
||||
default: 0
|
||||
@@ -607,7 +601,7 @@ coincellassemblyworkstation_device:
|
||||
type: integer
|
||||
gemo_juzhendianwei:
|
||||
default: 0
|
||||
description: 隔膜矩阵点位
|
||||
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
gemopanshu:
|
||||
default: 0
|
||||
@@ -619,7 +613,7 @@ coincellassemblyworkstation_device:
|
||||
type: boolean
|
||||
qiangtou_juzhendianwei:
|
||||
default: 0
|
||||
description: 枪头盒矩阵点位
|
||||
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
required:
|
||||
- elec_num
|
||||
|
||||
@@ -18,7 +18,6 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
degrees:
|
||||
description: 角度值
|
||||
type: number
|
||||
required:
|
||||
- degrees
|
||||
@@ -45,7 +44,6 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
description: 电机轴
|
||||
type: object
|
||||
required:
|
||||
- axis
|
||||
@@ -73,7 +71,6 @@ xyz_stepper_controller:
|
||||
properties:
|
||||
enable:
|
||||
default: true
|
||||
description: True为使能,False为失能
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
@@ -102,11 +99,9 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
description: 电机轴
|
||||
type: object
|
||||
enable:
|
||||
default: true
|
||||
description: True为使能,False为失能
|
||||
type: boolean
|
||||
required:
|
||||
- axis
|
||||
@@ -157,7 +152,6 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
description: 电机轴
|
||||
type: object
|
||||
required:
|
||||
- axis
|
||||
@@ -189,21 +183,16 @@ 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
|
||||
@@ -236,21 +225,16 @@ 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
|
||||
@@ -283,21 +267,16 @@ 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
|
||||
@@ -330,20 +309,15 @@ 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
|
||||
@@ -376,20 +350,15 @@ 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
|
||||
@@ -422,20 +391,15 @@ 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
|
||||
@@ -463,7 +427,6 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
revolutions:
|
||||
description: 圈数
|
||||
type: number
|
||||
required:
|
||||
- revolutions
|
||||
@@ -493,13 +456,10 @@ xyz_stepper_controller:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
description: 加速度(rpm/s)
|
||||
type: integer
|
||||
axis:
|
||||
description: 电机轴
|
||||
type: object
|
||||
speed:
|
||||
description: 运行速度(rpm),正值正转,负值反转
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
@@ -527,7 +487,6 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
steps:
|
||||
description: 步数
|
||||
type: integer
|
||||
required:
|
||||
- steps
|
||||
@@ -554,7 +513,6 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
steps:
|
||||
description: 步数
|
||||
type: integer
|
||||
required:
|
||||
- steps
|
||||
@@ -606,11 +564,9 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
description: 电机轴
|
||||
type: object
|
||||
timeout:
|
||||
default: 30.0
|
||||
description: 超时时间(秒)
|
||||
type: number
|
||||
required:
|
||||
- axis
|
||||
@@ -635,14 +591,11 @@ xyz_stepper_controller:
|
||||
properties:
|
||||
baudrate:
|
||||
default: 115200
|
||||
description: 波特率
|
||||
type: integer
|
||||
port:
|
||||
description: 串口端口名
|
||||
type: string
|
||||
timeout:
|
||||
default: 1.0
|
||||
description: 通信超时时间
|
||||
type: number
|
||||
required:
|
||||
- port
|
||||
|
||||
@@ -510,11 +510,9 @@ liquid_handler:
|
||||
goal:
|
||||
properties:
|
||||
msg:
|
||||
description: information to be printed
|
||||
type: string
|
||||
seconds:
|
||||
default: 0
|
||||
description: seconds to wait
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
@@ -2965,22 +2963,15 @@ 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
|
||||
@@ -4838,13 +4829,11 @@ 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
|
||||
@@ -4894,17 +4883,14 @@ 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
|
||||
@@ -4915,7 +4901,6 @@ liquid_handler.biomek:
|
||||
type: object
|
||||
type: array
|
||||
slot_on_deck:
|
||||
description: 甲板上的槽位
|
||||
type: integer
|
||||
required:
|
||||
- resource_tracker
|
||||
@@ -5051,27 +5036,20 @@ 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: 输出文件路径
|
||||
description: 输出JSON文件路径
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
@@ -146,7 +146,7 @@ neware_battery_test_system:
|
||||
goal:
|
||||
properties:
|
||||
plate_num:
|
||||
description: 盘号 (1 或 2),如果为None则返回所有盘的状态
|
||||
description: 盘号 (1 或 2),如果为null则返回所有盘的状态
|
||||
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" 仅上传 CSV 文件)
|
||||
description: 文件通配符模式,例如 *.csv 或 Battery_*.nda
|
||||
type: string
|
||||
oss_prefix:
|
||||
description: OSS 对象前缀,默认使用类初始化时的配置
|
||||
description: OSS对象路径前缀(默认使用self.oss_prefix)
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
@@ -336,25 +336,19 @@ 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
|
||||
@@ -366,7 +360,6 @@ neware_battery_test_system:
|
||||
default: 20
|
||||
type: number
|
||||
timeout:
|
||||
description: 通信超时时间(秒)
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
|
||||
@@ -207,12 +207,8 @@ 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
|
||||
@@ -309,17 +305,12 @@ 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,7 +456,6 @@ syringe_pump_with_valve.runze.SY03B-T06:
|
||||
goal:
|
||||
properties:
|
||||
volume:
|
||||
description: 'absolute position of the plunger, unit: mL'
|
||||
type: number
|
||||
required:
|
||||
- volume
|
||||
@@ -482,7 +481,6 @@ syringe_pump_with_valve.runze.SY03B-T06:
|
||||
goal:
|
||||
properties:
|
||||
volume:
|
||||
description: 'absolute position of the plunger, unit: mL'
|
||||
type: number
|
||||
required:
|
||||
- volume
|
||||
@@ -689,10 +687,8 @@ 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
|
||||
@@ -1007,7 +1003,6 @@ syringe_pump_with_valve.runze.SY03B-T08:
|
||||
goal:
|
||||
properties:
|
||||
volume:
|
||||
description: 'absolute position of the plunger, unit: mL'
|
||||
type: number
|
||||
required:
|
||||
- volume
|
||||
@@ -1033,7 +1028,6 @@ syringe_pump_with_valve.runze.SY03B-T08:
|
||||
goal:
|
||||
properties:
|
||||
volume:
|
||||
description: 'absolute position of the plunger, unit: mL'
|
||||
type: number
|
||||
required:
|
||||
- volume
|
||||
@@ -1240,10 +1234,8 @@ 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=0, End=1)
|
||||
description: 终点计时点 (Start=开始前, End=结束后)
|
||||
type: integer
|
||||
end_step_key:
|
||||
default: ''
|
||||
@@ -40,11 +40,11 @@ reaction_station.bioyond:
|
||||
type: string
|
||||
start_point:
|
||||
default: 0
|
||||
description: 起点计时点 (Start=0, End=1)
|
||||
description: 起点计时点 (Start=开始前, End=结束后)
|
||||
type: integer
|
||||
start_step_key:
|
||||
default: ''
|
||||
description: 起点步骤Key (可选, 默认为空则自动选择)
|
||||
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择)
|
||||
type: string
|
||||
required:
|
||||
- duration
|
||||
@@ -91,7 +91,6 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
json_str:
|
||||
description: 订单参数的JSON字符串
|
||||
type: string
|
||||
required:
|
||||
- json_str
|
||||
@@ -118,7 +117,6 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
workflow_ids:
|
||||
description: 要删除的工作流ID数组
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
@@ -147,7 +145,6 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
json_str:
|
||||
description: 'JSON格式的字符串,包含:'
|
||||
type: string
|
||||
required:
|
||||
- json_str
|
||||
@@ -200,7 +197,6 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
web_workflow_json:
|
||||
description: JSON 格式的网页工作流列表
|
||||
type: string
|
||||
required:
|
||||
- web_workflow_json
|
||||
@@ -232,10 +228,8 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
reactor_id:
|
||||
description: 反应器编号 (1-5)
|
||||
type: integer
|
||||
temperature:
|
||||
description: 目标温度 (°C)
|
||||
type: number
|
||||
required:
|
||||
- reactor_id
|
||||
@@ -263,7 +257,6 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
preintake_id:
|
||||
description: 通量ID
|
||||
type: string
|
||||
required:
|
||||
- preintake_id
|
||||
@@ -345,7 +338,6 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
value:
|
||||
description: 工作流 ID 列表
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
@@ -373,7 +365,6 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
workflow_id:
|
||||
description: 工作流ID
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
@@ -433,11 +424,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'
|
||||
@@ -445,14 +436,14 @@ reaction_station.bioyond:
|
||||
type: string
|
||||
titration_type:
|
||||
default: '1'
|
||||
description: 是否滴定(NO=1, YES=2)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
type: string
|
||||
torque_variation:
|
||||
default: 2
|
||||
description: 是否观察(NO=1, YES=2)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
type: integer
|
||||
volume:
|
||||
description: 分液量(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -534,11 +525,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'
|
||||
@@ -546,15 +537,15 @@ reaction_station.bioyond:
|
||||
type: string
|
||||
titration_type:
|
||||
default: '1'
|
||||
description: 是否滴定(NO=1, YES=2)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
type: string
|
||||
torque_variation:
|
||||
default: 1
|
||||
description: 是否观察(int类型, 1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
type: integer
|
||||
volume:
|
||||
default: '350'
|
||||
description: 分液质量(g)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
@@ -602,28 +593,26 @@ reaction_station.bioyond:
|
||||
description: 物料名称
|
||||
type: string
|
||||
solvents:
|
||||
description: '溶剂信息的字典或JSON字符串(可选),格式如下:
|
||||
|
||||
{'
|
||||
description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume'
|
||||
type: string
|
||||
temperature:
|
||||
default: 25.0
|
||||
description: 温度设定(C)
|
||||
description: 温度设定(°C),默认25.00
|
||||
type: number
|
||||
time:
|
||||
default: '360'
|
||||
description: 观察时间(分钟)
|
||||
description: 观察时间(分钟),默认360
|
||||
type: string
|
||||
titration_type:
|
||||
default: '1'
|
||||
description: 是否滴定(NO=1, YES=2)
|
||||
description: 是否滴定(NO=否, YES=是),默认NO
|
||||
type: string
|
||||
torque_variation:
|
||||
default: 2
|
||||
description: 是否观察(NO=1, YES=2)
|
||||
description: 是否观察 (NO=否, YES=是),默认YES
|
||||
type: integer
|
||||
volume:
|
||||
description: 分液量(μL),直接指定体积(可选,如果提供solvents则自动计算)
|
||||
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -682,32 +671,33 @@ reaction_station.bioyond:
|
||||
description: 物料名称
|
||||
type: string
|
||||
extracted_actuals:
|
||||
description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume
|
||||
description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定)
|
||||
type: string
|
||||
feeding_order_data:
|
||||
description: feeding_order JSON字符串或对象,用于获取m二酐值
|
||||
description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例:
|
||||
{"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}'
|
||||
type: string
|
||||
temperature:
|
||||
default: 25.0
|
||||
description: 温度(C)
|
||||
description: 温度设定(°C),默认25.00
|
||||
type: number
|
||||
time:
|
||||
default: '90'
|
||||
description: 观察时间(分钟)
|
||||
description: 观察时间(分钟),默认90
|
||||
type: string
|
||||
titration_type:
|
||||
default: '2'
|
||||
description: 是否滴定(NO=1, YES=2),默认2
|
||||
description: 是否滴定(NO=否, YES=是),默认YES
|
||||
type: string
|
||||
torque_variation:
|
||||
default: 2
|
||||
description: 是否观察(NO=1, YES=2)
|
||||
description: 是否观察 (NO=否, YES=是),默认YES
|
||||
type: integer
|
||||
volume_formula:
|
||||
description: 分液公式(μL),如果提供则直接使用,否则自动计算
|
||||
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
||||
type: string
|
||||
x_value:
|
||||
description: 手工输入的x值,格式如 "1-2-3"
|
||||
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -748,7 +738,7 @@ reaction_station.bioyond:
|
||||
type: string
|
||||
temperature:
|
||||
default: 25.0
|
||||
description: 温度(C)
|
||||
description: 温度设定(°C)
|
||||
type: number
|
||||
time:
|
||||
default: '0'
|
||||
@@ -756,14 +746,14 @@ reaction_station.bioyond:
|
||||
type: string
|
||||
titration_type:
|
||||
default: '1'
|
||||
description: 是否滴定(NO=1, YES=2)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
type: string
|
||||
torque_variation:
|
||||
default: 1
|
||||
description: 是否观察(NO=1, YES=2)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
type: integer
|
||||
volume_formula:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume_formula
|
||||
@@ -796,7 +786,7 @@ reaction_station.bioyond:
|
||||
description: 任务名称
|
||||
type: string
|
||||
workflow_name:
|
||||
description: 合并后的工作流名称
|
||||
description: 工作流名称
|
||||
type: string
|
||||
required:
|
||||
- workflow_name
|
||||
@@ -829,15 +819,15 @@ reaction_station.bioyond:
|
||||
goal:
|
||||
properties:
|
||||
assign_material_name:
|
||||
description: 物料名称(不能为空)
|
||||
description: 物料名称
|
||||
type: string
|
||||
cutoff:
|
||||
default: '900000'
|
||||
description: 粘度上限(需为有效数字字符串,默认 "900000")
|
||||
description: 粘度上限
|
||||
type: string
|
||||
temperature:
|
||||
default: -10.0
|
||||
description: 温度设定(C,范围:-50.00 至 100.00)
|
||||
description: 温度设定(°C)
|
||||
type: number
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -919,11 +909,11 @@ reaction_station.bioyond:
|
||||
description: 物料名称(用于获取试剂瓶位ID)
|
||||
type: string
|
||||
material_id:
|
||||
description: 粉末类型ID, Salt=1, Flour=2, BTDA=3
|
||||
description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟)
|
||||
type: string
|
||||
temperature:
|
||||
default: 25.0
|
||||
description: 温度设定(C)
|
||||
description: 温度设定(°C)
|
||||
type: number
|
||||
time:
|
||||
default: '0'
|
||||
@@ -931,7 +921,7 @@ reaction_station.bioyond:
|
||||
type: string
|
||||
torque_variation:
|
||||
default: 1
|
||||
description: 是否观察(NO=1, YES=2)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
type: integer
|
||||
required:
|
||||
- material_id
|
||||
@@ -955,13 +945,10 @@ 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,8 +198,6 @@ 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
|
||||
@@ -243,8 +241,6 @@ 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
|
||||
@@ -288,7 +284,6 @@ 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
|
||||
|
||||
@@ -709,8 +709,6 @@ 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
|
||||
@@ -754,8 +752,6 @@ 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
|
||||
@@ -799,7 +795,6 @@ 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,7 +2179,6 @@ virtual_multiway_valve:
|
||||
goal:
|
||||
properties:
|
||||
port_number:
|
||||
description: 端口号 (1-8)
|
||||
type: integer
|
||||
required:
|
||||
- port_number
|
||||
@@ -2226,7 +2225,6 @@ virtual_multiway_valve:
|
||||
goal:
|
||||
properties:
|
||||
port_number:
|
||||
description: 目标端口号 (1-8)
|
||||
type: integer
|
||||
required:
|
||||
- port_number
|
||||
@@ -2263,7 +2261,6 @@ virtual_multiway_valve:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
description: 目标位置 (0-8) 或位置字符串
|
||||
type: string
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
@@ -2307,7 +2304,6 @@ virtual_multiway_valve:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
description: 目标位置 (0-8) 或位置字符串
|
||||
type: string
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
@@ -4219,7 +4215,6 @@ virtual_solenoid_valve:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
description: '"ON"/"OFF" 或 "OPEN"/"CLOSED"'
|
||||
type: string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
@@ -4263,7 +4258,6 @@ virtual_solenoid_valve:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
description: '"OPEN"/"CLOSED" 或其他控制命令'
|
||||
type: string
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
@@ -4424,20 +4418,16 @@ 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
|
||||
@@ -4449,7 +4439,6 @@ virtual_solid_dispenser:
|
||||
type: string
|
||||
vessel:
|
||||
additionalProperties: false
|
||||
description: 目标容器
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
@@ -5579,10 +5568,8 @@ virtual_transfer_pump:
|
||||
goal:
|
||||
properties:
|
||||
velocity:
|
||||
description: 拉取速度 (ml/s)
|
||||
type: number
|
||||
volume:
|
||||
description: 要拉取的体积 (ml)
|
||||
type: number
|
||||
required:
|
||||
- volume
|
||||
@@ -5609,10 +5596,8 @@ virtual_transfer_pump:
|
||||
goal:
|
||||
properties:
|
||||
velocity:
|
||||
description: 推出速度 (ml/s)
|
||||
type: number
|
||||
volume:
|
||||
description: 要推出的体积 (ml)
|
||||
type: number
|
||||
required:
|
||||
- volume
|
||||
@@ -5708,12 +5693,10 @@ 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
|
||||
@@ -5862,10 +5845,8 @@ 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: 在允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟)
|
||||
description: 允许上样后等待分钟数
|
||||
type: number
|
||||
required: []
|
||||
title: StartWorkflow_Goal
|
||||
@@ -492,15 +492,12 @@ 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,7 +217,6 @@ zhida_gcms:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
description: Base64编码的CSV数据(ROS2参数名)
|
||||
type: string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
@@ -258,7 +257,6 @@ zhida_gcms:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
description: CSV文件路径(ROS2参数名)
|
||||
type: string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
@@ -291,15 +289,12 @@ 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
|
||||
|
||||
@@ -271,7 +271,6 @@ 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()
|
||||
@@ -287,10 +286,6 @@ 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
|
||||
@@ -566,47 +561,13 @@ 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",
|
||||
@@ -637,10 +598,12 @@ 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]:
|
||||
@@ -836,7 +799,6 @@ 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:
|
||||
@@ -866,11 +828,7 @@ 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 = detect_placeholder_keys(params)
|
||||
@@ -889,12 +847,7 @@ 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,
|
||||
description=(action_args or {}).get("description") or method_doc_info.get("description", ""),
|
||||
result_schema=result_schema,
|
||||
),
|
||||
"schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema),
|
||||
"goal_default": goal_default,
|
||||
"handles": handles,
|
||||
"placeholder_keys": pk,
|
||||
@@ -933,11 +886,7 @@ 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", [])
|
||||
|
||||
@@ -1030,10 +979,7 @@ class Registry:
|
||||
"schema": schema,
|
||||
"goal_default": goal_default,
|
||||
"handles": handles,
|
||||
"placeholder_keys": {
|
||||
**detect_placeholder_keys(method_params),
|
||||
**(action_args.get("placeholder_keys") or {}),
|
||||
},
|
||||
"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
|
||||
@@ -1042,22 +988,13 @@ class Registry:
|
||||
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__", ast_meta.get("init_docstring"), import_map=imap
|
||||
)
|
||||
config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap)
|
||||
data_schema = self._generate_status_schema_from_ast(
|
||||
ast_meta.get("status_properties", {}), imap
|
||||
)
|
||||
@@ -1105,6 +1042,7 @@ 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",
|
||||
@@ -1134,10 +1072,12 @@ 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(
|
||||
@@ -1867,7 +1807,7 @@ class Registry:
|
||||
else:
|
||||
action_key = f"auto-{k}"
|
||||
goal_schema = self._generate_unilab_json_command_schema(
|
||||
v["args"], docstring=v.get("docstring"), import_map=enhanced_import_map
|
||||
v["args"], import_map=enhanced_import_map
|
||||
)
|
||||
ret_type = v.get("return_type", "")
|
||||
result_schema = None
|
||||
@@ -1876,13 +1816,7 @@ 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}", {})
|
||||
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,
|
||||
)
|
||||
new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema)
|
||||
old_schema = old_cfg.get("schema", {})
|
||||
if old_schema:
|
||||
preserve_field_descriptions(new_schema, old_schema)
|
||||
@@ -1948,12 +1882,6 @@ 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,
|
||||
@@ -1974,8 +1902,7 @@ class Registry:
|
||||
|
||||
device_config["init_param_schema"] = {}
|
||||
init_schema = self._generate_unilab_json_command_schema(
|
||||
enhanced_info["init_params"],
|
||||
docstring=enhanced_info.get("init_docstring"),
|
||||
enhanced_info["init_params"], "__init__",
|
||||
import_map=enhanced_import_map,
|
||||
)
|
||||
device_config["init_param_schema"]["config"] = init_schema
|
||||
@@ -2022,9 +1949,7 @@ 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", {})
|
||||
@@ -2216,7 +2141,6 @@ class Registry:
|
||||
"unilabos_device_id": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "设备ID",
|
||||
"description": "UniLabOS设备ID,用于指定执行动作的具体设备实例",
|
||||
},
|
||||
**schema["properties"]["goal"]["properties"],
|
||||
@@ -2288,14 +2212,7 @@ 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单例实例
|
||||
"""
|
||||
@@ -2309,12 +2226,7 @@ def build_registry(
|
||||
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()
|
||||
|
||||
@@ -36,40 +36,16 @@ 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]:
|
||||
"""
|
||||
解析 docstring,提取描述和参数说明。
|
||||
|
||||
支持:
|
||||
- Google-style ``Args:`` / ``Parameters:`` 小节
|
||||
- 直接参数行 ``field: desc``
|
||||
- 带显示名参数行 ``field[Display Name]: desc``
|
||||
解析 Google-style docstring,提取描述和参数说明。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"description": "短描述",
|
||||
"params": {"param1": "参数1描述", ...},
|
||||
"param_display_names": {"param1": "显示名", ...},
|
||||
}
|
||||
{"description": "短描述", "params": {"param1": "参数1描述", ...}}
|
||||
"""
|
||||
result: Dict[str, Any] = {"description": "", "params": {}, "param_display_names": {}}
|
||||
result: Dict[str, Any] = {"description": "", "params": {}}
|
||||
if not docstring:
|
||||
return result
|
||||
|
||||
@@ -77,53 +53,33 @@ 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 = []
|
||||
|
||||
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:]:
|
||||
for line in lines[1:]:
|
||||
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:
|
||||
flush_current_param()
|
||||
current_section = section_match.group(1).lower()
|
||||
in_args = current_section in ("args", "arguments", "parameters", "params")
|
||||
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")
|
||||
continue
|
||||
|
||||
parse_as_param = in_args or current_section is None
|
||||
if not parse_as_param:
|
||||
if not in_args:
|
||||
continue
|
||||
|
||||
if ":" in stripped:
|
||||
flush_current_param()
|
||||
if ":" in stripped and not stripped.startswith(" "):
|
||||
if current_param is not None:
|
||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
||||
param_part, _, desc_part = stripped.partition(":")
|
||||
param_name, display_name = _parse_docstring_param_header(param_part)
|
||||
param_name = param_part.strip().split("(")[0].strip()
|
||||
current_param = param_name
|
||||
current_display_name = display_name
|
||||
current_desc_parts = [desc_part.strip()]
|
||||
elif current_param is not None:
|
||||
aline = line
|
||||
@@ -133,7 +89,8 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
||||
aline = aline[1:]
|
||||
current_desc_parts.append(aline.strip())
|
||||
|
||||
flush_current_param()
|
||||
if current_param is not None:
|
||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from . import sirna_materials # noqa: F401 ensure @resource classes are importable for PLR deserialize
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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)
|
||||
@@ -25,11 +23,6 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -108,83 +101,6 @@ 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,
|
||||
@@ -234,146 +150,12 @@ 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, "row_col")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "PeptideStation_Deck",
|
||||
size_x: float = 3500.0,
|
||||
size_y: float = 1800.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:
|
||||
# 已有序列化子资源,跳过 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 setup(self) -> None:
|
||||
# 多肽工作站仓库配置
|
||||
# 基于 2026-05-09 live API probe 发现的实际仓库拓扑 (12个仓库)
|
||||
# 数据来源: temp_benyao/peptide/_logs/warehouse_discovery_raw_live_2026-05-09.json
|
||||
self.warehouses = {
|
||||
# 主自动化堆栈 - live API: code 10-17 -> x=17, y=10,显示为 10 行×17 列
|
||||
"自动化堆栈": bioyond_warehouse_numeric_stack(
|
||||
"自动化堆栈", rows=10, columns=17, bioyond_axis="xy_col_row", bioyond_key_axis="row_col"
|
||||
),
|
||||
|
||||
# 低温存储
|
||||
"低温冰箱仓库": bioyond_warehouse_live_grid(
|
||||
"低温冰箱仓库", rows=2, columns=3, slot_keys=["1", "2", "3", "4", "5", "6"]
|
||||
),
|
||||
|
||||
# 移液站库位
|
||||
"Tecan移液站库": bioyond_warehouse_live_grid(
|
||||
"Tecan移液站库", rows=1, columns=18, slot_keys=[str(index) for index in range(1, 19)]
|
||||
),
|
||||
"G3移液站库": bioyond_warehouse_live_grid(
|
||||
"G3移液站库",
|
||||
rows=1,
|
||||
columns=18,
|
||||
slot_keys=["1", "2", "3", "4", "垃圾桶", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18"],
|
||||
),
|
||||
"IDOT移液站库": bioyond_warehouse_live_grid(
|
||||
"IDOT移液站库",
|
||||
rows=1,
|
||||
columns=12,
|
||||
slot_keys=[f"0009-{index:04d}" for index in range(1, 13)],
|
||||
),
|
||||
|
||||
# 缓冲库位
|
||||
"G3缓冲库": bioyond_warehouse_live_grid(
|
||||
"G3缓冲库", rows=1, columns=5, slot_keys=[str(index) for index in range(1, 6)]
|
||||
),
|
||||
"盖板缓冲库": bioyond_warehouse_live_grid(
|
||||
"盖板缓冲库", rows=1, columns=7, slot_keys=[str(index) for index in range(1, 8)]
|
||||
),
|
||||
"配平板缓冲库": bioyond_warehouse_live_grid(
|
||||
"配平板缓冲库", rows=1, columns=3, slot_keys=[str(index) for index in range(1, 4)]
|
||||
),
|
||||
"IDOT缓冲库": bioyond_warehouse_live_grid(
|
||||
"IDOT缓冲库", rows=1, columns=2, slot_keys=["1", "1"]
|
||||
),
|
||||
"固相合成板底座缓冲位": bioyond_warehouse_live_grid(
|
||||
"固相合成板底座缓冲位",
|
||||
rows=1,
|
||||
columns=4,
|
||||
slot_keys=[f"0015-{index:04d}" for index in range(1, 5)],
|
||||
),
|
||||
|
||||
# 设备库位
|
||||
"离心机库位": bioyond_warehouse_live_grid(
|
||||
"离心机库位", rows=1, columns=4, slot_keys=[f"0017-{index:04d}" for index in range(1, 5)]
|
||||
),
|
||||
"热封膜机位": bioyond_warehouse_live_grid(
|
||||
"热封膜机位", rows=1, columns=2, slot_keys=[f"0016-{index:04d}" for index in range(1, 3)]
|
||||
),
|
||||
}
|
||||
|
||||
# 仓库位置布局 (需根据实际硬件布局调整)
|
||||
self.warehouse_locations = {
|
||||
"自动化堆栈": Coordinate(0.0, 0.0, 0.0),
|
||||
"Tecan移液站库": Coordinate(0.0, 1150.0, 0.0),
|
||||
"G3移液站库": Coordinate(0.0, 1300.0, 0.0),
|
||||
"IDOT移液站库": Coordinate(0.0, 1450.0, 0.0),
|
||||
"G3缓冲库": Coordinate(0.0, 1600.0, 0.0),
|
||||
"盖板缓冲库": Coordinate(850.0, 1600.0, 0.0),
|
||||
"低温冰箱仓库": Coordinate(2700.0, 0.0, 0.0),
|
||||
"配平板缓冲库": Coordinate(2700.0, 300.0, 0.0),
|
||||
"IDOT缓冲库": Coordinate(2700.0, 450.0, 0.0),
|
||||
"固相合成板底座缓冲位": Coordinate(2700.0, 600.0, 0.0),
|
||||
"离心机库位": Coordinate(2700.0, 750.0, 0.0),
|
||||
"热封膜机位": Coordinate(2700.0, 900.0, 0.0),
|
||||
}
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
def YB_Deck(name: str) -> Deck:
|
||||
by=BIOYOND_YB_Deck(name=name)
|
||||
by.setup()
|
||||
return by
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Sirna Station Material Resource Definitions
|
||||
|
||||
Defines PyLabRobot resource classes for Bioyond Sirna station materials.
|
||||
Each class is decorated with @resource for AST-based registry discovery.
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from pylabrobot.resources import Plate, TipRack, Container
|
||||
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_sirna_g3_200ul_tip_rack",
|
||||
category=["labware", "tip_rack"],
|
||||
description="G3-200ul枪头盒 for Sirna station",
|
||||
)
|
||||
class BioyondSirna_G3_200ul_TipRack(TipRack):
|
||||
"""G3-200ul tip rack for Sirna liquid handling."""
|
||||
|
||||
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("model", "bioyond_sirna_g3_200ul_tip_rack")
|
||||
kwargs.setdefault("with_tips", True)
|
||||
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
|
||||
kwargs["ordering"] = OrderedDict()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_sirna_g3_50ul_tip_rack",
|
||||
category=["labware", "tip_rack"],
|
||||
description="G3-50ul枪头盒 for Sirna station",
|
||||
)
|
||||
class BioyondSirna_G3_50ul_TipRack(TipRack):
|
||||
"""G3-50ul tip rack for Sirna liquid handling."""
|
||||
|
||||
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("model", "bioyond_sirna_g3_50ul_tip_rack")
|
||||
kwargs.setdefault("with_tips", True)
|
||||
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
|
||||
kwargs["ordering"] = OrderedDict()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_sirna_384_well_plate",
|
||||
category=["labware", "plate"],
|
||||
description="384孔板 for Sirna assays",
|
||||
)
|
||||
class BioyondSirna_384WellPlate(Plate):
|
||||
"""384-well plate for Sirna reporter gene detection."""
|
||||
|
||||
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("model", "bioyond_sirna_384_well_plate")
|
||||
kwargs.setdefault("plate_type", "skirted")
|
||||
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
|
||||
kwargs["ordering"] = OrderedDict()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_sirna_cell_culture_plate",
|
||||
category=["labware", "plate"],
|
||||
description="细胞培养板 for Sirna cell culture",
|
||||
)
|
||||
class BioyondSirna_CellCulturePlate(Plate):
|
||||
"""Cell culture plate for Sirna experiments."""
|
||||
|
||||
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("model", "bioyond_sirna_cell_culture_plate")
|
||||
kwargs.setdefault("plate_type", "skirted")
|
||||
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
|
||||
kwargs["ordering"] = OrderedDict()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@resource(
|
||||
id="bioyond_sirna_reagent_trough",
|
||||
category=["labware", "trough"],
|
||||
description="试剂槽 for Sirna reagents",
|
||||
)
|
||||
class BioyondSirna_ReagentTrough(Container):
|
||||
"""Reagent trough for Sirna station reagents (RiboGreen, etc.)."""
|
||||
|
||||
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_sirna_reagent_trough")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
# Material type code mapping for dynamic instantiation
|
||||
MATERIAL_TYPE_CODE_TO_CLASS = {
|
||||
"0016": BioyondSirna_G3_200ul_TipRack,
|
||||
"0017": BioyondSirna_G3_50ul_TipRack,
|
||||
"0015": BioyondSirna_384WellPlate,
|
||||
"0001": BioyondSirna_CellCulturePlate,
|
||||
"0006": BioyondSirna_ReagentTrough,
|
||||
}
|
||||
|
||||
|
||||
def get_material_class_by_type_code(type_code: str):
|
||||
"""Get resource class by Bioyond material type code.
|
||||
|
||||
Args:
|
||||
type_code: Bioyond materialTypeCode (e.g., "0016", "0017")
|
||||
|
||||
Returns:
|
||||
Resource class or None if not found
|
||||
"""
|
||||
return MATERIAL_TYPE_CODE_TO_CLASS.get(type_code)
|
||||
@@ -1,180 +1,5 @@
|
||||
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",
|
||||
) -> 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
|
||||
locations = [
|
||||
Coordinate(dx + col * item_dx, dy + row * item_dy, dz)
|
||||
for row in range(num_items_y)
|
||||
for col in range(num_items_x)
|
||||
]
|
||||
holders = create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=locations,
|
||||
resource_size_x=127.0,
|
||||
resource_size_y=86.0,
|
||||
resource_size_z=25.0,
|
||||
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=dy + item_dy * num_items_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",
|
||||
) -> 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
|
||||
locations = [
|
||||
Coordinate(dx + col * item_dx, dy + row * item_dy, dz)
|
||||
for row in range(num_items_y)
|
||||
for col in range(num_items_x)
|
||||
]
|
||||
holders = create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=locations,
|
||||
resource_size_x=127.0,
|
||||
resource_size_y=86.0,
|
||||
resource_size_z=25.0,
|
||||
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=dy + item_dy * num_items_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:
|
||||
|
||||
@@ -42,7 +42,7 @@ def canonicalize_nodes_data(
|
||||
Returns:
|
||||
ResourceTreeSet: 标准化后的资源树集合
|
||||
"""
|
||||
print_status(f"{len(nodes)} Resources loaded", "info")
|
||||
print_status(f"{len(nodes)} Resources loaded:", "info")
|
||||
|
||||
# 第一步:基本预处理(处理graphml的label字段)
|
||||
outer_host_node_id = None
|
||||
@@ -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("barCode") or material.get("code") or ""
|
||||
plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
|
||||
plr_material.unilabos_uuid = str(uuid.uuid4())
|
||||
|
||||
# ⭐ 保存 Bioyond 原始信息到 unilabos_extra(用于出库时查询)
|
||||
@@ -864,22 +864,11 @@ 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坐标映射:
|
||||
# - 历史 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)
|
||||
# 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...)
|
||||
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
|
||||
@@ -923,43 +912,10 @@ 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:
|
||||
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}"
|
||||
)
|
||||
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
|
||||
# 物料尺寸已在放入warehouse前根据需要进行了交换
|
||||
warehouse[idx] = plr_material
|
||||
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)})"
|
||||
)
|
||||
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
|
||||
else:
|
||||
logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}")
|
||||
else:
|
||||
|
||||
@@ -33,76 +33,10 @@ _USE_UV: Optional[bool] = None
|
||||
def _has_uv() -> bool:
|
||||
global _USE_UV
|
||||
if _USE_UV is 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
|
||||
_USE_UV = shutil.which("uv") is not None
|
||||
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,
|
||||
@@ -119,7 +53,7 @@ def _install_packages(
|
||||
return True
|
||||
|
||||
is_chinese = _is_chinese_locale()
|
||||
installers = _installer_candidates()
|
||||
use_uv = _has_uv()
|
||||
failed: List[str] = []
|
||||
|
||||
for pkg in packages:
|
||||
@@ -129,30 +63,35 @@ def _install_packages(
|
||||
else:
|
||||
print_status(f"正在{action_word} {pkg}...", "info")
|
||||
|
||||
pkg_installed = False
|
||||
last_error = "unknown error"
|
||||
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"])
|
||||
|
||||
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)
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode == 0:
|
||||
installer = "uv" if use_uv else "pip"
|
||||
print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success")
|
||||
else:
|
||||
stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error"
|
||||
print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error")
|
||||
failed.append(pkg)
|
||||
except subprocess.TimeoutExpired:
|
||||
print_status(f"× {pkg} {action_word}超时 (300s)", "error")
|
||||
failed.append(pkg)
|
||||
except Exception as e:
|
||||
print_status(f"× {pkg} {action_word}异常: {e}", "error")
|
||||
failed.append(pkg)
|
||||
|
||||
if failed:
|
||||
|
||||
@@ -206,7 +206,6 @@ class ImportManager:
|
||||
"ast_analysis_success": False,
|
||||
"import_map": {},
|
||||
"init_params": [],
|
||||
"init_docstring": None,
|
||||
"status_methods": {},
|
||||
"action_methods": {},
|
||||
}
|
||||
@@ -252,7 +251,6 @@ 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: {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import ast
|
||||
import json
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
from .common import WorkflowGraph, RegistryAdapter
|
||||
|
||||
Json = Dict[str, Any]
|
||||
|
||||
# ---------------- Converter ----------------
|
||||
|
||||
class DeviceMethodConverter:
|
||||
"""
|
||||
- 字段统一:resource_name(原 device_class)、template_name(原 action_key)
|
||||
- params 单层;inputs 使用 'params.' 前缀
|
||||
- SimpleGraph.add_workflow_node 负责变量连线与边
|
||||
"""
|
||||
def __init__(self, device_registry: Optional[Dict[str, Any]] = None):
|
||||
self.graph = WorkflowGraph()
|
||||
self.variable_sources: Dict[str, Dict[str, Any]] = {} # var -> {node_id, output_name}
|
||||
self.instance_to_resource: Dict[str, Optional[str]] = {} # 实例名 -> resource_name
|
||||
self.node_id_counter: int = 0
|
||||
self.registry = RegistryAdapter(device_registry or {})
|
||||
|
||||
# ---- helpers ----
|
||||
def _new_node_id(self) -> int:
|
||||
nid = self.node_id_counter
|
||||
self.node_id_counter += 1
|
||||
return nid
|
||||
|
||||
def _assign_targets(self, targets) -> List[str]:
|
||||
names: List[str] = []
|
||||
import ast
|
||||
if isinstance(targets, ast.Tuple):
|
||||
for elt in targets.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
names.append(elt.id)
|
||||
elif isinstance(targets, ast.Name):
|
||||
names.append(targets.id)
|
||||
return names
|
||||
|
||||
def _extract_device_instantiation(self, node) -> Optional[Tuple[str, str]]:
|
||||
import ast
|
||||
if not isinstance(node.value, ast.Call):
|
||||
return None
|
||||
callee = node.value.func
|
||||
if isinstance(callee, ast.Name):
|
||||
class_name = callee.id
|
||||
elif isinstance(callee, ast.Attribute) and isinstance(callee.value, ast.Name):
|
||||
class_name = callee.attr
|
||||
else:
|
||||
return None
|
||||
if isinstance(node.targets[0], ast.Name):
|
||||
instance = node.targets[0].id
|
||||
return instance, class_name
|
||||
return None
|
||||
|
||||
def _extract_call(self, call) -> Tuple[str, str, Dict[str, Any], str]:
|
||||
import ast
|
||||
owner_name, method_name, call_kind = "", "", "func"
|
||||
if isinstance(call.func, ast.Attribute):
|
||||
method_name = call.func.attr
|
||||
if isinstance(call.func.value, ast.Name):
|
||||
owner_name = call.func.value.id
|
||||
call_kind = "instance" if owner_name in self.instance_to_resource else "class_or_module"
|
||||
elif isinstance(call.func.value, ast.Attribute) and isinstance(call.func.value.value, ast.Name):
|
||||
owner_name = call.func.value.attr
|
||||
call_kind = "class_or_module"
|
||||
elif isinstance(call.func, ast.Name):
|
||||
method_name = call.func.id
|
||||
call_kind = "func"
|
||||
|
||||
def pack(node):
|
||||
if isinstance(node, ast.Name):
|
||||
return {"type": "variable", "value": node.id}
|
||||
if isinstance(node, ast.Constant):
|
||||
return {"type": "constant", "value": node.value}
|
||||
if isinstance(node, ast.Dict):
|
||||
return {"type": "dict", "value": self._parse_dict(node)}
|
||||
if isinstance(node, ast.List):
|
||||
return {"type": "list", "value": self._parse_list(node)}
|
||||
return {"type": "raw", "value": ast.unparse(node) if hasattr(ast, "unparse") else str(node)}
|
||||
|
||||
args: Dict[str, Any] = {}
|
||||
pos: List[Any] = []
|
||||
for a in call.args:
|
||||
pos.append(pack(a))
|
||||
for kw in call.keywords:
|
||||
args[kw.arg] = pack(kw.value)
|
||||
if pos:
|
||||
args["_positional"] = pos
|
||||
return owner_name, method_name, args, call_kind
|
||||
|
||||
def _parse_dict(self, node) -> Dict[str, Any]:
|
||||
import ast
|
||||
out: Dict[str, Any] = {}
|
||||
for k, v in zip(node.keys, node.values):
|
||||
if isinstance(k, ast.Constant):
|
||||
key = str(k.value)
|
||||
if isinstance(v, ast.Name):
|
||||
out[key] = f"var:{v.id}"
|
||||
elif isinstance(v, ast.Constant):
|
||||
out[key] = v.value
|
||||
elif isinstance(v, ast.Dict):
|
||||
out[key] = self._parse_dict(v)
|
||||
elif isinstance(v, ast.List):
|
||||
out[key] = self._parse_list(v)
|
||||
return out
|
||||
|
||||
def _parse_list(self, node) -> List[Any]:
|
||||
import ast
|
||||
out: List[Any] = []
|
||||
for elt in node.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
out.append(f"var:{elt.id}")
|
||||
elif isinstance(elt, ast.Constant):
|
||||
out.append(elt.value)
|
||||
elif isinstance(elt, ast.Dict):
|
||||
out.append(self._parse_dict(elt))
|
||||
elif isinstance(elt, ast.List):
|
||||
out.append(self._parse_list(elt))
|
||||
return out
|
||||
|
||||
def _normalize_var_tokens(self, x: Any) -> Any:
|
||||
if isinstance(x, str) and x.startswith("var:"):
|
||||
return {"__var__": x[4:]}
|
||||
if isinstance(x, list):
|
||||
return [self._normalize_var_tokens(i) for i in x]
|
||||
if isinstance(x, dict):
|
||||
return {k: self._normalize_var_tokens(v) for k, v in x.items()}
|
||||
return x
|
||||
|
||||
def _make_params_payload(self, resource_name: Optional[str], template_name: str, call_args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
input_keys = self.registry.get_action_input_keys(resource_name, template_name) if resource_name else []
|
||||
defaults = self.registry.get_action_goal_default(resource_name, template_name) if resource_name else {}
|
||||
params: Dict[str, Any] = dict(defaults)
|
||||
|
||||
def unpack(p):
|
||||
t, v = p.get("type"), p.get("value")
|
||||
if t == "variable":
|
||||
return {"__var__": v}
|
||||
if t == "dict":
|
||||
return self._normalize_var_tokens(v)
|
||||
if t == "list":
|
||||
return self._normalize_var_tokens(v)
|
||||
return v
|
||||
|
||||
for k, p in call_args.items():
|
||||
if k == "_positional":
|
||||
continue
|
||||
params[k] = unpack(p)
|
||||
|
||||
pos = call_args.get("_positional", [])
|
||||
if pos:
|
||||
if input_keys:
|
||||
for i, p in enumerate(pos):
|
||||
if i >= len(input_keys):
|
||||
break
|
||||
name = input_keys[i]
|
||||
if name in params:
|
||||
continue
|
||||
params[name] = unpack(p)
|
||||
else:
|
||||
for i, p in enumerate(pos):
|
||||
params[f"arg_{i}"] = unpack(p)
|
||||
return params
|
||||
|
||||
# ---- handlers ----
|
||||
def _on_assign(self, stmt):
|
||||
import ast
|
||||
inst = self._extract_device_instantiation(stmt)
|
||||
if inst:
|
||||
instance, code_class = inst
|
||||
resource_name = self.registry.resolve_resource_by_classname(code_class)
|
||||
self.instance_to_resource[instance] = resource_name
|
||||
return
|
||||
|
||||
if isinstance(stmt.value, ast.Call):
|
||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
||||
if kind == "instance":
|
||||
device_key = owner
|
||||
resource_name = self.instance_to_resource.get(owner)
|
||||
else:
|
||||
device_key = owner
|
||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
||||
|
||||
module = self.registry.get_device_module(resource_name)
|
||||
params = self._make_params_payload(resource_name, method, call_args)
|
||||
|
||||
nid = self._new_node_id()
|
||||
self.graph.add_workflow_node(
|
||||
nid,
|
||||
device_key=device_key,
|
||||
resource_name=resource_name, # ✅
|
||||
module=module,
|
||||
template_name=method, # ✅
|
||||
params=params,
|
||||
variable_sources=self.variable_sources,
|
||||
add_ready_if_no_vars=True,
|
||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
||||
)
|
||||
|
||||
out_vars = self._assign_targets(stmt.targets[0])
|
||||
for var in out_vars:
|
||||
self.variable_sources[var] = {"node_id": nid, "output_name": "result"}
|
||||
|
||||
def _on_expr(self, stmt):
|
||||
import ast
|
||||
if not isinstance(stmt.value, ast.Call):
|
||||
return
|
||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
||||
if kind == "instance":
|
||||
device_key = owner
|
||||
resource_name = self.instance_to_resource.get(owner)
|
||||
else:
|
||||
device_key = owner
|
||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
||||
|
||||
module = self.registry.get_device_module(resource_name)
|
||||
params = self._make_params_payload(resource_name, method, call_args)
|
||||
|
||||
nid = self._new_node_id()
|
||||
self.graph.add_workflow_node(
|
||||
nid,
|
||||
device_key=device_key,
|
||||
resource_name=resource_name, # ✅
|
||||
module=module,
|
||||
template_name=method, # ✅
|
||||
params=params,
|
||||
variable_sources=self.variable_sources,
|
||||
add_ready_if_no_vars=True,
|
||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
||||
)
|
||||
|
||||
def convert(self, python_code: str):
|
||||
tree = ast.parse(python_code)
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, ast.Assign):
|
||||
self._on_assign(stmt)
|
||||
elif isinstance(stmt, ast.Expr):
|
||||
self._on_expr(stmt)
|
||||
return self
|
||||
@@ -1,131 +0,0 @@
|
||||
from typing import List, Any, Dict
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
return {"error": error_msg, "success": False}
|
||||
Reference in New Issue
Block a user