mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 11:24:19 +00:00
add:skill&agent
This commit is contained in:
24
.cursor/skills/add-device/SKILL.md
Normal file
24
.cursor/skills/add-device/SKILL.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: add-device
|
||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Walks through device category selection (thing model), communication protocol, command protocol collection, driver creation, registry YAML, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, configure device registry, or mentions 接入设备/添加设备/设备驱动/物模型.
|
||||
---
|
||||
|
||||
# 添加新设备到 Uni-Lab-OS
|
||||
|
||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南并严格遵循。
|
||||
|
||||
该指南包含:
|
||||
- 8 步完整流程(设备类别、通信协议、指令收集、接口对齐、驱动创建、注册表、图文件、验证)
|
||||
- 所有物模型代码模板(注射泵、电磁阀、蠕动泵、温控、电机等)
|
||||
- 通信协议代码片段(Serial、Modbus、TCP、HTTP、OPC UA)
|
||||
- 现有设备接口快照(用于第四步对齐,包含参数名、status_types、方法签名)
|
||||
- 常见错误检查清单
|
||||
|
||||
**Cursor 工具映射:**
|
||||
|
||||
| 指南中的操作 | Cursor 中使用的工具 |
|
||||
|---|---|
|
||||
| 向用户确认设备类别、协议等信息 | 使用 AskQuestion 工具 |
|
||||
| 搜索已有设备注册表 | 使用 Grep 在 `unilabos/registry/devices/` 中搜索 |
|
||||
| 读取用户提供的协议文档/SDK 代码 | 使用 Read 工具 |
|
||||
| 第四步对齐:查找同类设备接口 | 优先使用 Grep 搜索仓库中的最新注册表;指南中的「现有设备接口快照」作为兜底参考 |
|
||||
323
.cursor/skills/add-protocol/SKILL.md
Normal file
323
.cursor/skills/add-protocol/SKILL.md
Normal file
@@ -0,0 +1,323 @@
|
||||
---
|
||||
name: add-protocol
|
||||
description: Guide for adding new experiment protocols to Uni-Lab-OS (添加新实验操作协议). Walks through ROS Action definition, Pydantic model creation, protocol generator implementation, and registration. Use when the user wants to add a new protocol, create a compile function, implement an experiment operation, or mentions 协议/protocol/编译/compile/实验操作.
|
||||
---
|
||||
|
||||
# 添加新实验操作协议(Protocol)
|
||||
|
||||
Protocol 是对实验有意义的完整动作(如泵转移、过滤、溶解),需要多设备协同。`compile/` 中的生成函数根据设备连接图将抽象操作"编译"为设备指令序列。
|
||||
|
||||
添加一个 Protocol 需修改 **6 个文件**,按以下流程执行。
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认协议信息
|
||||
|
||||
向用户确认:
|
||||
|
||||
| 信息 | 示例 |
|
||||
|------|------|
|
||||
| 协议英文名 | `MyNewProtocol` |
|
||||
| 操作描述 | 将固体样品研磨至目标粒径 |
|
||||
| Goal 参数(必需 + 可选) | `vessel: dict`, `time: float = 300.0` |
|
||||
| Result 字段 | `success: bool`, `message: str` |
|
||||
| 需要哪些设备协同 | 研磨器、搅拌器 |
|
||||
|
||||
---
|
||||
|
||||
## 第二步:创建 ROS Action 定义
|
||||
|
||||
路径:`unilabos_msgs/action/<ActionName>.action`
|
||||
|
||||
三段式结构(Goal / Result / Feedback),用 `---` 分隔:
|
||||
|
||||
```
|
||||
# Goal
|
||||
Resource vessel
|
||||
float64 time
|
||||
string mode
|
||||
---
|
||||
# Result
|
||||
bool success
|
||||
string return_info
|
||||
---
|
||||
# Feedback
|
||||
string status
|
||||
string current_device
|
||||
builtin_interfaces/Duration time_spent
|
||||
builtin_interfaces/Duration time_remaining
|
||||
```
|
||||
|
||||
**类型映射:**
|
||||
|
||||
| Python 类型 | ROS 类型 | 说明 |
|
||||
|------------|----------|------|
|
||||
| `dict` | `Resource` | 容器/设备引用,自定义消息类型 |
|
||||
| `float` | `float64` | |
|
||||
| `int` | `int32` | |
|
||||
| `str` | `string` | |
|
||||
| `bool` | `bool` | |
|
||||
|
||||
> `Resource` 是 `unilabos_msgs/msg/Resource.msg` 中定义的自定义消息类型。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:注册 Action 到 CMakeLists
|
||||
|
||||
在 `unilabos_msgs/CMakeLists.txt` 的 `set(action_files ...)` 块中添加:
|
||||
|
||||
```cmake
|
||||
"action/MyNewAction.action"
|
||||
```
|
||||
|
||||
> 调试时需编译:`cd unilabos_msgs && colcon build && source ./install/local_setup.sh && cd ..`
|
||||
> PR 合并后 CI/CD 自动发布,`mamba update ros-humble-unilabos-msgs` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 第四步:创建 Pydantic 模型
|
||||
|
||||
在 `unilabos/messages/__init__.py` 中添加(位于 `# Start Protocols` 和 `# End Protocols` 之间):
|
||||
|
||||
```python
|
||||
class MyNewProtocol(BaseModel):
|
||||
# === 必需参数 ===
|
||||
vessel: dict = Field(..., description="目标容器")
|
||||
|
||||
# === 可选参数 ===
|
||||
time: float = Field(300.0, description="操作时间 (秒)")
|
||||
mode: str = Field("default", description="操作模式")
|
||||
|
||||
def model_post_init(self, __context):
|
||||
"""参数验证和修正"""
|
||||
if self.time <= 0:
|
||||
self.time = 300.0
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- 参数名必须与 `.action` 文件中 Goal 字段完全一致
|
||||
- `dict` 类型对应 `.action` 中的 `Resource`
|
||||
- 将类名加入文件末尾的 `__all__` 列表
|
||||
|
||||
---
|
||||
|
||||
## 第五步:实现协议生成函数
|
||||
|
||||
路径:`unilabos/compile/<protocol_name>_protocol.py`
|
||||
|
||||
```python
|
||||
import networkx as nx
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def generate_my_new_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
time: float = 300.0,
|
||||
mode: str = "default",
|
||||
**kwargs,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将 MyNewProtocol 编译为设备动作序列。
|
||||
|
||||
Args:
|
||||
G: 设备连接图(NetworkX),节点为设备/容器,边为物理连接
|
||||
vessel: 目标容器 {"id": "reactor_1"}
|
||||
time: 操作时间(秒)
|
||||
mode: 操作模式
|
||||
|
||||
Returns:
|
||||
动作列表,每个元素为:
|
||||
- dict: 单步动作
|
||||
- list[dict]: 并行动作
|
||||
"""
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
actions = []
|
||||
|
||||
# 查找相关设备(通过图的连接关系)
|
||||
# 生成动作序列
|
||||
actions.append({
|
||||
"device_id": "target_device_id",
|
||||
"action_name": "some_action",
|
||||
"action_kwargs": {"param": "value"}
|
||||
})
|
||||
|
||||
# 等待
|
||||
actions.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": time}
|
||||
})
|
||||
|
||||
return actions
|
||||
```
|
||||
|
||||
### 动作字典格式
|
||||
|
||||
```python
|
||||
# 单步动作(发给子设备)
|
||||
{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}}
|
||||
|
||||
# 发给工作站自身
|
||||
{"device_id": "self", "action_name": "my_action", "action_kwargs": {...}}
|
||||
|
||||
# 等待
|
||||
{"action_name": "wait", "action_kwargs": {"time": 5.0}}
|
||||
|
||||
# 并行动作(列表嵌套)
|
||||
[
|
||||
{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}},
|
||||
{"device_id": "stirrer_1", "action_name": "start_stir", "action_kwargs": {"stir_speed": 300}}
|
||||
]
|
||||
```
|
||||
|
||||
### 关于 `vessel` 参数类型
|
||||
|
||||
现有协议的 `vessel` 参数类型不统一:
|
||||
- 新协议趋势:使用 `dict`(如 `{"id": "reactor_1"}`)
|
||||
- 旧协议:使用 `str`(如 `"reactor_1"`)
|
||||
- 兼容写法:`Union[str, dict]`
|
||||
|
||||
**建议新协议统一使用 `dict` 类型**,通过 `get_vessel()` 兼容两种输入。
|
||||
|
||||
### 公共工具函数(`unilabos/compile/utils/`)
|
||||
|
||||
| 函数 | 用途 |
|
||||
|------|------|
|
||||
| `get_vessel(vessel)` | 解析容器参数为 `(vessel_id, vessel_data)`,兼容 dict 和 str |
|
||||
| `find_solvent_vessel(G, solvent)` | 根据溶剂名查找容器(精确→命名规则→模糊→液体类型) |
|
||||
| `find_reagent_vessel(G, reagent)` | 根据试剂名查找容器(支持固体和液体) |
|
||||
| `find_connected_stirrer(G, vessel)` | 查找与容器相连的搅拌器 |
|
||||
| `find_solid_dispenser(G)` | 查找固体加样器 |
|
||||
|
||||
### 协议内专属查找函数
|
||||
|
||||
许多协议在自己的文件内定义了专属的 `find_*` 函数(不在 `utils/` 中)。编写新协议时,优先复用 `utils/` 中的公共函数;如需特殊查找逻辑,在协议文件内部定义即可:
|
||||
|
||||
```python
|
||||
def find_my_special_device(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与容器相关的特殊设备"""
|
||||
for node in G.nodes():
|
||||
if 'my_device_type' in G.nodes[node].get('class', '').lower():
|
||||
return node
|
||||
raise ValueError("未找到特殊设备")
|
||||
```
|
||||
|
||||
### 复用已有协议
|
||||
|
||||
复杂协议通常组合已有协议:
|
||||
|
||||
```python
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
actions.extend(generate_pump_protocol_with_rinsing(
|
||||
G, from_vessel=solvent_vessel, to_vessel=vessel, volume=volume
|
||||
))
|
||||
```
|
||||
|
||||
### 图查询模式
|
||||
|
||||
```python
|
||||
# 查找与容器相连的特定类型设备
|
||||
for neighbor in G.neighbors(vessel_id):
|
||||
node_data = G.nodes[neighbor]
|
||||
if "heater" in node_data.get("class", ""):
|
||||
heater_id = neighbor
|
||||
break
|
||||
|
||||
# 查找最短路径(泵转移)
|
||||
path = nx.shortest_path(G, source=from_vessel_id, target=to_vessel_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:注册协议生成函数
|
||||
|
||||
在 `unilabos/compile/__init__.py` 中:
|
||||
|
||||
1. 顶部添加导入:
|
||||
|
||||
```python
|
||||
from .my_new_protocol import generate_my_new_protocol
|
||||
```
|
||||
|
||||
2. 在 `action_protocol_generators` 字典中添加映射:
|
||||
|
||||
```python
|
||||
action_protocol_generators = {
|
||||
# ... 已有协议
|
||||
MyNewProtocol: generate_my_new_protocol,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第七步:配置图文件
|
||||
|
||||
在工作站的图文件中,将协议名加入 `protocol_type`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"class": "workstation",
|
||||
"config": {
|
||||
"protocol_type": ["PumpTransferProtocol", "MyNewProtocol"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第八步:验证
|
||||
|
||||
```bash
|
||||
# 1. 模块可导入
|
||||
python -c "from unilabos.messages import MyNewProtocol; print(MyNewProtocol.model_fields)"
|
||||
|
||||
# 2. 生成函数可导入
|
||||
python -c "from unilabos.compile import action_protocol_generators; print(list(action_protocol_generators.keys()))"
|
||||
|
||||
# 3. 启动测试(可选)
|
||||
unilab -g <graph>.json --complete_registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
协议接入进度:
|
||||
- [ ] 1. 确认协议名、参数、涉及设备
|
||||
- [ ] 2. 创建 .action 文件 (unilabos_msgs/action/<Name>.action)
|
||||
- [ ] 3. 注册到 CMakeLists.txt
|
||||
- [ ] 4. 创建 Pydantic 模型 (unilabos/messages/__init__.py) + 更新 __all__
|
||||
- [ ] 5. 实现生成函数 (unilabos/compile/<name>_protocol.py)
|
||||
- [ ] 6. 注册到 compile/__init__.py
|
||||
- [ ] 7. 配置图文件 protocol_type
|
||||
- [ ] 8. 验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现复杂协议时,详见 [reference.md](reference.md):协议运行时数据流、mock graph 测试模式、单位解析工具(`unit_parser.py`)、复杂协议组合模式(以 dissolve 为例)。
|
||||
|
||||
---
|
||||
|
||||
## 现有协议速查
|
||||
|
||||
| 协议 | Pydantic 类 | 生成函数 | 核心参数 |
|
||||
|------|-------------|---------|---------|
|
||||
| 泵转移 | `PumpTransferProtocol` | `generate_pump_protocol_with_rinsing` | `from_vessel, to_vessel, volume` |
|
||||
| 简单转移 | `TransferProtocol` | `generate_pump_protocol` | `from_vessel, to_vessel, volume` |
|
||||
| 加样 | `AddProtocol` | `generate_add_protocol` | `vessel, reagent, volume` |
|
||||
| 过滤 | `FilterProtocol` | `generate_filter_protocol` | `vessel, filtrate_vessel` |
|
||||
| 溶解 | `DissolveProtocol` | `generate_dissolve_protocol` | `vessel, solvent, volume` |
|
||||
| 加热/冷却 | `HeatChillProtocol` | `generate_heat_chill_protocol` | `vessel, temp, time` |
|
||||
| 搅拌 | `StirProtocol` | `generate_stir_protocol` | `vessel, time` |
|
||||
| 分离 | `SeparateProtocol` | `generate_separate_protocol` | `from_vessel, separation_vessel, solvent` |
|
||||
| 蒸发 | `EvaporateProtocol` | `generate_evaporate_protocol` | `vessel, pressure, temp, time` |
|
||||
| 清洗 | `CleanProtocol` | `generate_clean_protocol` | `vessel, solvent, volume` |
|
||||
| 离心 | `CentrifugeProtocol` | `generate_centrifuge_protocol` | `vessel, speed, time` |
|
||||
| 抽气充气 | `EvacuateAndRefillProtocol` | `generate_evacuateandrefill_protocol` | `vessel, gas` |
|
||||
207
.cursor/skills/add-protocol/reference.md
Normal file
207
.cursor/skills/add-protocol/reference.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# 协议高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含协议运行时数据流、测试模式、单位解析工具和复杂协议组合模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 协议运行时数据流
|
||||
|
||||
从图文件到协议执行的完整链路:
|
||||
|
||||
```
|
||||
实验图 JSON
|
||||
↓ graphio.read_node_link_json()
|
||||
physical_setup_graph (NetworkX DiGraph)
|
||||
↓ ROS2WorkstationNode._setup_protocol_names(protocol_type)
|
||||
为每个 protocol_name 创建 ActionServer
|
||||
↓ 收到 Action Goal
|
||||
_create_protocol_execute_callback()
|
||||
↓ convert_from_ros_msg_with_mapping(goal, mapping)
|
||||
protocol_kwargs (Python dict)
|
||||
↓ 向 Host 查询 Resource 类型参数的当前状态
|
||||
protocol_kwargs 更新(vessel 带上 children、data 等)
|
||||
↓ protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)
|
||||
List[Dict] 动作序列
|
||||
↓ 逐步 execute_single_action / 并行 create_task
|
||||
子设备 ActionClient 执行
|
||||
```
|
||||
|
||||
### `_setup_protocol_names` 核心逻辑
|
||||
|
||||
```python
|
||||
def _setup_protocol_names(self, protocol_type):
|
||||
if isinstance(protocol_type, str):
|
||||
self.protocol_names = [p.strip() for p in protocol_type.split(",")]
|
||||
else:
|
||||
self.protocol_names = protocol_type
|
||||
self.protocol_action_mappings = {}
|
||||
for protocol_name in self.protocol_names:
|
||||
protocol_type = globals()[protocol_name] # 从 messages 模块取 Pydantic 类
|
||||
self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type)
|
||||
```
|
||||
|
||||
### `_create_protocol_execute_callback` 关键步骤
|
||||
|
||||
1. `convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])` — ROS Goal → Python dict
|
||||
2. 对 `Resource` 类型字段,通过 `resource_get` Service 查询 Host 的最新资源状态
|
||||
3. `protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)` — 调用编译函数
|
||||
4. 遍历 steps:`dict` 串行执行,`list` 并行执行
|
||||
5. `execute_single_action` 通过 `_action_clients[device_id]` 向子设备发送 Action Goal
|
||||
6. 执行完毕后通过 `resource_update` Service 更新资源状态
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试模式
|
||||
|
||||
### 2.1 协议文件内测试函数
|
||||
|
||||
许多协议文件末尾有 `test_*` 函数,主要测试参数解析工具:
|
||||
|
||||
```python
|
||||
def test_dissolve_protocol():
|
||||
"""测试溶解协议的各种参数解析"""
|
||||
volumes = ["10 mL", "?", 10.0, "1 L", "500 μL"]
|
||||
for vol in volumes:
|
||||
result = parse_volume_input(vol)
|
||||
print(f"体积解析: {vol} → {result}mL")
|
||||
|
||||
masses = ["2.9 g", "?", 2.5, "500 mg"]
|
||||
for mass in masses:
|
||||
result = parse_mass_input(mass)
|
||||
print(f"质量解析: {mass} → {result}g")
|
||||
```
|
||||
|
||||
### 2.2 使用 mock graph 测试协议生成器
|
||||
|
||||
推荐的端到端测试模式:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import networkx as nx
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def topology_graph():
|
||||
"""创建测试拓扑图"""
|
||||
G = nx.DiGraph()
|
||||
G.add_node("flask_1", **{"class": "flask", "type": "container"})
|
||||
G.add_node("stirrer_1", **{"class": "virtual_stirrer", "type": "device"})
|
||||
G.add_edge("stirrer_1", "flask_1")
|
||||
return G
|
||||
|
||||
|
||||
def test_generate_stir_protocol(topology_graph):
|
||||
"""测试搅拌协议生成"""
|
||||
actions = generate_stir_protocol(
|
||||
G=topology_graph,
|
||||
vessel="flask_1",
|
||||
time="5 min",
|
||||
stir_speed=300.0
|
||||
)
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["device_id"] == "stirrer_1"
|
||||
```
|
||||
|
||||
**要点:**
|
||||
- 用 `nx.DiGraph()` 构建最小拓扑
|
||||
- `add_node(id, **attrs)` 设置 `class`、`type`、`data` 等
|
||||
- `add_edge(src, dst)` 建立物理连接
|
||||
- 协议内的 `find_*` 函数依赖这些节点和边
|
||||
|
||||
---
|
||||
|
||||
## 3. 单位解析工具
|
||||
|
||||
路径:`unilabos/compile/utils/unit_parser.py`
|
||||
|
||||
| 函数 | 输入 | 返回 | 默认值 |
|
||||
|------|------|------|--------|
|
||||
| `parse_volume_input(input, default_unit)` | `"100 mL"`, `"2.5 L"`, `"500 μL"`, `10.0`, `"?"` | mL (float) | 50.0 |
|
||||
| `parse_mass_input(input)` | `"19.3 g"`, `"500 mg"`, `2.5`, `"?"` | g (float) | 1.0 |
|
||||
| `parse_time_input(input)` | `"30 min"`, `"1 h"`, `"300"`, `60.0`, `"?"` | 秒 (float) | 60.0 |
|
||||
|
||||
支持的单位:
|
||||
|
||||
- **体积**: mL, L, μL/uL, milliliter, liter, microliter
|
||||
- **质量**: g, mg, kg, gram, milligram, kilogram
|
||||
- **时间**: s/sec/second, min/minute, h/hr/hour, d/day
|
||||
|
||||
特殊值 `"?"`、`"unknown"`、`"tbd"` 返回默认值。
|
||||
|
||||
---
|
||||
|
||||
## 4. 复杂协议组合模式
|
||||
|
||||
以 `dissolve_protocol` 为例,展示如何组合多个子操作:
|
||||
|
||||
### 整体流程
|
||||
|
||||
```
|
||||
1. 解析参数 (parse_volume_input, parse_mass_input, parse_time_input)
|
||||
2. 设备发现 (find_connected_heatchill, find_connected_stirrer, find_solid_dispenser)
|
||||
3. 判断溶解类型 (液体 vs 固体)
|
||||
4. 组合动作序列:
|
||||
a. heat_chill_start / start_stir (启动加热/搅拌)
|
||||
b. wait (等待温度稳定)
|
||||
c. pump_protocol_with_rinsing (液体转移, 通过 extend 拼接)
|
||||
或 add_solid (固体加样)
|
||||
d. heat_chill / stir / wait (溶解等待)
|
||||
e. heat_chill_stop (停止加热)
|
||||
```
|
||||
|
||||
### 关键代码模式
|
||||
|
||||
**设备发现 → 条件组合:**
|
||||
|
||||
```python
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
solid_dispenser_id = find_solid_dispenser(G)
|
||||
|
||||
actions = []
|
||||
|
||||
# 启动阶段
|
||||
if heatchill_id and temp > 25.0:
|
||||
actions.append({
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp}
|
||||
})
|
||||
actions.append({"action_name": "wait", "action_kwargs": {"time": 30}})
|
||||
elif stirrer_id:
|
||||
actions.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "stir_speed": stir_speed}
|
||||
})
|
||||
|
||||
# 转移阶段(复用已有协议)
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G, from_vessel=solvent_vessel, to_vessel=vessel_id, volume=volume
|
||||
)
|
||||
actions.extend(pump_actions)
|
||||
|
||||
# 等待阶段
|
||||
if heatchill_id:
|
||||
actions.append({
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp, "time": time}
|
||||
})
|
||||
else:
|
||||
actions.append({"action_name": "wait", "action_kwargs": {"time": time}})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| 协议执行回调 | `unilabos/ros/nodes/presets/workstation.py` |
|
||||
| ROS 消息映射 | `unilabos/ros/msgs/message_converter.py` |
|
||||
| 物理拓扑图 | `unilabos/resources/graphio.py` (`physical_setup_graph`) |
|
||||
| 单位解析 | `unilabos/compile/utils/unit_parser.py` |
|
||||
| 容器解析 | `unilabos/compile/utils/vessel_parser.py` |
|
||||
| 溶解协议(组合示例) | `unilabos/compile/dissolve_protocol.py` |
|
||||
371
.cursor/skills/add-resource/SKILL.md
Normal file
371
.cursor/skills/add-resource/SKILL.md
Normal file
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: add-resource
|
||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Covers Bottle, Carrier, Deck, WareHouse definitions and registry YAML. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
||||
---
|
||||
|
||||
# 添加新物料资源
|
||||
|
||||
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认资源类型
|
||||
|
||||
向用户确认需要添加的资源类型:
|
||||
|
||||
| 类型 | 基类 | 用途 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
||||
|
||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
||||
|
||||
还需确认:
|
||||
- 资源所属的项目/场景(如 bioyond、battery、通用)
|
||||
- 尺寸参数(直径、高度、最大容积等)
|
||||
- 布局参数(行列数、间距等)
|
||||
|
||||
---
|
||||
|
||||
## 第二步:创建资源定义
|
||||
|
||||
### 文件位置
|
||||
|
||||
```
|
||||
unilabos/resources/
|
||||
├── <project>/ # 按项目分组
|
||||
│ ├── bottles.py # Bottle 工厂函数
|
||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
||||
│ ├── warehouses.py # WareHouse 工厂函数
|
||||
│ └── decks.py # Deck 类定义
|
||||
├── itemized_carrier.py # Bottle, BottleCarrier, ItemizedCarrier 基类
|
||||
├── warehouse.py # WareHouse 基类
|
||||
└── container.py # 通用容器
|
||||
```
|
||||
|
||||
### 2A. 添加 Bottle(工厂函数)
|
||||
|
||||
```python
|
||||
from unilabos.resources.itemized_carrier import Bottle
|
||||
|
||||
|
||||
def My_Reagent_Bottle(
|
||||
name: str,
|
||||
diameter: float = 70.0, # 瓶体直径 (mm)
|
||||
height: float = 120.0, # 瓶体高度 (mm)
|
||||
max_volume: float = 500000.0, # 最大容积 (μL)
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建试剂瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="My_Reagent_Bottle", # 唯一标识,用于注册表和物料映射
|
||||
)
|
||||
```
|
||||
|
||||
**Bottle 参数:**
|
||||
- `name`: 实例名称(运行时唯一)
|
||||
- `diameter`: 瓶体直径 (mm)
|
||||
- `height`: 瓶体高度 (mm)
|
||||
- `max_volume`: 最大容积 (**μL**,注意单位!500mL = 500000)
|
||||
- `barcode`: 条形码(可选)
|
||||
- `model`: 模型标识,与注册表 key 一致
|
||||
|
||||
### 2B. 添加 BottleCarrier(工厂函数)
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import ResourceHolder
|
||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||
|
||||
|
||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
||||
"""创建 3x2 六槽位载架"""
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3, # 列数
|
||||
num_items_y=2, # 行数
|
||||
dx=10.0, # X 起始偏移
|
||||
dy=10.0, # Y 起始偏移
|
||||
dz=5.0, # Z 偏移
|
||||
item_dx=42.0, # X 间距
|
||||
item_dy=35.0, # Y 间距
|
||||
size_x=20.0, # 槽位宽
|
||||
size_y=20.0, # 槽位深
|
||||
size_z=50.0, # 槽位高
|
||||
)
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=146.0, # 载架总宽
|
||||
size_y=80.0, # 载架总深
|
||||
size_z=55.0, # 载架总高
|
||||
sites=sites,
|
||||
model="My_6SlotCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
|
||||
# 预装 Bottle(可选)
|
||||
ordering = ["A01", "A02", "A03", "B01", "B02", "B03"]
|
||||
for i in range(6):
|
||||
carrier[i] = My_Reagent_Bottle(f"{ordering[i]}")
|
||||
|
||||
return carrier
|
||||
```
|
||||
|
||||
### 2C. 添加 WareHouse(工厂函数)
|
||||
|
||||
```python
|
||||
from unilabos.resources.warehouse import warehouse_factory
|
||||
|
||||
|
||||
def my_warehouse_4x4(name: str) -> "WareHouse":
|
||||
"""创建 4行x4列 堆栈仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, # 列数
|
||||
num_items_y=4, # 行数
|
||||
num_items_z=1, # 层数(通常为 1)
|
||||
dx=137.0, # X 起始偏移
|
||||
dy=96.0, # Y 起始偏移
|
||||
dz=120.0, # Z 起始偏移
|
||||
item_dx=137.0, # X 间距
|
||||
item_dy=125.0, # Y 间距
|
||||
item_dz=10.0, # Z 间距(多层时用)
|
||||
resource_size_x=127.0, # 槽位宽
|
||||
resource_size_y=85.0, # 槽位深
|
||||
resource_size_z=100.0, # 槽位高
|
||||
model="my_warehouse_4x4",
|
||||
)
|
||||
```
|
||||
|
||||
**`warehouse_factory` 参数说明:**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `num_items_x/y/z` | 列数/行数/层数 |
|
||||
| `dx, dy, dz` | 第一个槽位的起始坐标偏移 |
|
||||
| `item_dx, item_dy, item_dz` | 相邻槽位间距 |
|
||||
| `resource_size_x/y/z` | 单个槽位的物理尺寸 |
|
||||
| `col_offset` | 列命名偏移(如设 4 则从 A05 开始) |
|
||||
| `row_offset` | 行命名偏移(如设 5 则从 F 行开始) |
|
||||
| `layout` | 排序方式:`"col-major"`(列优先,默认)/ `"row-major"`(行优先) |
|
||||
| `removed_positions` | 要移除的位置索引列表 |
|
||||
|
||||
自动生成 `ResourceHolder` 槽位,命名规则为 `A01, B01, C01, D01, A02, ...`(列优先)或 `A01, A02, A03, A04, B01, ...`(行优先)。
|
||||
|
||||
### 2D. 添加 Deck(类定义)
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck, Coordinate
|
||||
|
||||
|
||||
class MyStation_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "MyStation_Deck",
|
||||
size_x: float = 2700.0,
|
||||
size_y: float = 1080.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()
|
||||
|
||||
def setup(self) -> None:
|
||||
self.warehouses = {
|
||||
"仓库A": my_warehouse_4x4("仓库A"),
|
||||
"仓库B": my_warehouse_4x4("仓库B"),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"仓库A": Coordinate(-200.0, 400.0, 0.0),
|
||||
"仓库B": Coordinate(2350.0, 400.0, 0.0),
|
||||
}
|
||||
for wh_name, wh in self.warehouses.items():
|
||||
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
||||
```
|
||||
|
||||
**Deck 要点:**
|
||||
- 继承 `pylabrobot.resources.Deck`
|
||||
- `setup()` 创建 WareHouse 并通过 `assign_child_resource` 放置到指定坐标
|
||||
- `setup` 参数控制是否在构造时自动调用 `setup()`(图文件中通过 `config.setup: true` 触发)
|
||||
|
||||
---
|
||||
|
||||
## 第三步:创建注册表 YAML
|
||||
|
||||
路径:`unilabos/registry/resources/<project>/<type>.yaml`
|
||||
|
||||
### Bottle 注册
|
||||
|
||||
```yaml
|
||||
My_Reagent_Bottle:
|
||||
category:
|
||||
- bottles
|
||||
class:
|
||||
module: unilabos.resources.my_project.bottles:My_Reagent_Bottle
|
||||
type: pylabrobot
|
||||
description: 我的试剂瓶
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
### Carrier 注册
|
||||
|
||||
```yaml
|
||||
My_6SlotCarrier:
|
||||
category:
|
||||
- bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.my_project.bottle_carriers:My_6SlotCarrier
|
||||
type: pylabrobot
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
### Deck 注册
|
||||
|
||||
```yaml
|
||||
MyStation_Deck:
|
||||
category:
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.resources.my_project.decks:MyStation_Deck
|
||||
type: pylabrobot
|
||||
description: 我的工作站 Deck
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
**注册表规则:**
|
||||
- `class.module` 格式为 `python.module.path:ClassName_or_FunctionName`
|
||||
- `class.type` 固定为 `pylabrobot`
|
||||
- Key(如 `My_Reagent_Bottle`)必须与工厂函数名 / 类名一致
|
||||
- `category` 按类型标注(`bottles`, `bottle_carriers`, `deck` 等)
|
||||
|
||||
---
|
||||
|
||||
## 第四步:在图文件中引用
|
||||
|
||||
### Deck 在工作站中的引用
|
||||
|
||||
工作站节点通过 `deck` 字段引用,Deck 作为子节点:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck"],
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_project.decks:MyStation_Deck"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"config": {"type": "MyStation_Deck", "setup": true}
|
||||
}
|
||||
```
|
||||
|
||||
### 物料类型映射(外部系统对接时)
|
||||
|
||||
如果工作站需要与外部系统同步物料,在 config 中配置 `material_type_mappings`:
|
||||
|
||||
```json
|
||||
"material_type_mappings": {
|
||||
"My_Reagent_Bottle": ["试剂瓶", "external-type-uuid"],
|
||||
"My_6SlotCarrier": ["六槽载架", "external-type-uuid"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第五步:注册 PLR 扩展(如需要)
|
||||
|
||||
如果添加了新的 Deck 类,需要在 `unilabos/resources/plr_additional_res_reg.py` 中导入,使 `find_subclass` 能发现它:
|
||||
|
||||
```python
|
||||
def register():
|
||||
from unilabos.resources.my_project.decks import MyStation_Deck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:验证
|
||||
|
||||
```bash
|
||||
# 1. 资源可导入
|
||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
||||
|
||||
# 2. Deck 可创建
|
||||
python -c "
|
||||
from unilabos.resources.my_project.decks import MyStation_Deck
|
||||
d = MyStation_Deck('test', setup=True)
|
||||
print(d.children)
|
||||
"
|
||||
|
||||
# 3. 启动测试
|
||||
unilab -g <graph>.json --complete_registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
资源接入进度:
|
||||
- [ ] 1. 确定资源类型(Bottle / Carrier / WareHouse / Deck)
|
||||
- [ ] 2. 创建资源定义(工厂函数/类)
|
||||
- [ ] 3. 创建注册表 YAML (unilabos/registry/resources/<project>/<type>.yaml)
|
||||
- [ ] 4. 在图文件中引用(如需要)
|
||||
- [ ] 5. 注册 PLR 扩展(Deck 类需要)
|
||||
- [ ] 6. 验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现复杂资源系统时,详见 [reference.md](reference.md):类继承体系完整图、序列化/反序列化流程、Bioyond 物料双向同步、非瓶类资源(ElectrodeSheet / Magazine)、仓库工厂 layout 模式。
|
||||
|
||||
---
|
||||
|
||||
## 现有资源参考
|
||||
|
||||
| 项目 | Bottles | Carriers | WareHouses | Decks |
|
||||
|------|---------|----------|------------|-------|
|
||||
| bioyond | `bioyond/bottles.py` | `bioyond/bottle_carriers.py` | `bioyond/warehouses.py`, `YB_warehouses.py` | `bioyond/decks.py` |
|
||||
| battery | — | `battery/bottle_carriers.py` | — | — |
|
||||
| 通用 | — | — | `warehouse.py` | — |
|
||||
|
||||
### 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
| 资源注册表 | `unilabos/registry/resources/` |
|
||||
| 图文件加载 | `unilabos/resources/graphio.py` |
|
||||
| 资源跟踪器 | `unilabos/resources/resource_tracker.py` |
|
||||
292
.cursor/skills/add-resource/reference.md
Normal file
292
.cursor/skills/add-resource/reference.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 资源高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 类继承体系
|
||||
|
||||
```
|
||||
PyLabRobot
|
||||
├── Resource (PLR 基类)
|
||||
│ ├── Well
|
||||
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
||||
│ ├── Deck
|
||||
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
||||
│ ├── ResourceHolder → 槽位占位符
|
||||
│ └── Container
|
||||
│ └── Battery (unilabos) → 组装好的电池
|
||||
│
|
||||
├── ItemizedCarrier (unilabos, 继承 Resource)
|
||||
│ ├── BottleCarrier (unilabos) → 瓶载架
|
||||
│ └── WareHouse (unilabos) → 堆栈仓库
|
||||
│
|
||||
├── ItemizedResource (PLR)
|
||||
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
||||
│
|
||||
└── ResourceStack (PLR)
|
||||
└── Magazine (unilabos) → 子弹夹洞位
|
||||
```
|
||||
|
||||
### Bottle 类细节
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def __init__(self, name, diameter, height, max_volume,
|
||||
size_x=0.0, size_y=0.0, size_z=0.0,
|
||||
barcode=None, category="container", model=None, **kwargs):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
||||
size_y=diameter,
|
||||
size_z=height, # PLR 用 height 作为 size_z
|
||||
max_volume=max_volume,
|
||||
category=category,
|
||||
model=model,
|
||||
bottom_type="flat",
|
||||
cross_section_type="circle"
|
||||
)
|
||||
```
|
||||
|
||||
注意 `size_x = size_y = diameter`,`size_z = height`。
|
||||
|
||||
### ItemizedCarrier 核心方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
||||
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
||||
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
||||
| `capacity` | 总槽位数 |
|
||||
| `sites` | 所有槽位字典 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 序列化与反序列化
|
||||
|
||||
### PLR ↔ UniLab 转换
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
||||
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
||||
|
||||
### `from_plr_resources` 流程
|
||||
|
||||
```
|
||||
PLR Resource
|
||||
↓ build_uuid_mapping (递归生成 UUID)
|
||||
↓ resource.serialize() → dict
|
||||
↓ resource.serialize_all_state() → states
|
||||
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
||||
ResourceTreeSet
|
||||
```
|
||||
|
||||
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
||||
|
||||
### `to_plr_resources` 流程
|
||||
|
||||
```
|
||||
ResourceTreeSet
|
||||
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
||||
↓ node_to_plr_dict (转为 PLR 字典格式)
|
||||
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
||||
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
||||
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
||||
PLR Resource
|
||||
```
|
||||
|
||||
### Bottle 序列化
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
return {**data, "diameter": self.diameter, "height": self.height}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal=False):
|
||||
barcode_data = data.pop("barcode", None)
|
||||
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
||||
if barcode_data and isinstance(barcode_data, str):
|
||||
instance.barcode = barcode_data
|
||||
return instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Bioyond 物料同步
|
||||
|
||||
### 双向转换函数
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
||||
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
||||
|
||||
### `resource_bioyond_to_plr` 流程
|
||||
|
||||
```
|
||||
Bioyond 物料列表
|
||||
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
||||
↓ 对每个物料:
|
||||
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
||||
initialize_resource({"name": unique_name, "class": model})
|
||||
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
||||
↓ 处理 detail (子物料/坐标)
|
||||
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
||||
PLR 资源列表
|
||||
```
|
||||
|
||||
### `resource_plr_to_bioyond` 流程
|
||||
|
||||
```
|
||||
PLR 资源列表
|
||||
↓ 遍历每个资源:
|
||||
载架(capacity > 1): 生成 details 子物料 + 坐标
|
||||
单瓶: 直接映射
|
||||
↓ type_mapping 查找 typeId
|
||||
↓ warehouse_mapping 查找位置 UUID
|
||||
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
||||
Bioyond 物料列表
|
||||
```
|
||||
|
||||
### BioyondResourceSynchronizer
|
||||
|
||||
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
||||
|
||||
```python
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
all_data = []
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
||||
unilab_resources = resource_bioyond_to_plr(
|
||||
all_data,
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
deck=self.workstation.deck
|
||||
)
|
||||
# 更新 deck 上的资源
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 非瓶类资源
|
||||
|
||||
### ElectrodeSheet(极片)
|
||||
|
||||
路径:`unilabos/resources/battery/electrode_sheet.py`
|
||||
|
||||
```python
|
||||
class ElectrodeSheet(ResourcePLR):
|
||||
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
||||
_unilabos_state = {
|
||||
"diameter": 0.0,
|
||||
"thickness": 0.0,
|
||||
"mass": 0.0,
|
||||
"material_type": "",
|
||||
"color": "",
|
||||
"info": "",
|
||||
}
|
||||
```
|
||||
|
||||
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
||||
|
||||
### Battery(电池)
|
||||
|
||||
```python
|
||||
class Battery(Container):
|
||||
"""组装好的电池"""
|
||||
_unilabos_state = {
|
||||
"color": "",
|
||||
"electrolyte_name": "",
|
||||
"open_circuit_voltage": 0.0,
|
||||
}
|
||||
```
|
||||
|
||||
### Magazine / MagazineHolder(子弹夹)
|
||||
|
||||
```python
|
||||
class Magazine(ResourceStack):
|
||||
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
||||
# direction, max_sheets
|
||||
|
||||
class MagazineHolder(ItemizedResource):
|
||||
"""多洞位子弹夹"""
|
||||
# hole_diameter, hole_depth, max_sheets_per_hole
|
||||
```
|
||||
|
||||
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓库工厂模式参考
|
||||
|
||||
### 实际 warehouse 工厂函数示例
|
||||
|
||||
```python
|
||||
# 行优先 4x4 仓库
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="row-major", # A01,A02,A03,A04, B01,...
|
||||
)
|
||||
|
||||
# 右侧 4x4 仓库(列名偏移)
|
||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
col_offset=4, # A05,A06,A07,A08
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
# 竖向仓库(站内试剂存放)
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1, num_items_y=2, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="vertical-col-major",
|
||||
)
|
||||
|
||||
# 行偏移(F 行开始)
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, num_items_y=5, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
||||
row_offset=row_offset, # 0→A行起,5→F行起
|
||||
layout="row-major",
|
||||
)
|
||||
```
|
||||
|
||||
### layout 类型说明
|
||||
|
||||
| layout | 命名顺序 | 适用场景 |
|
||||
|--------|---------|---------|
|
||||
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
||||
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
||||
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
||||
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
||||
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
||||
| 电池资源 | `unilabos/resources/battery/` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
500
.cursor/skills/add-workstation/SKILL.md
Normal file
500
.cursor/skills/add-workstation/SKILL.md
Normal file
@@ -0,0 +1,500 @@
|
||||
---
|
||||
name: add-workstation
|
||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Walks through workstation type selection, sub-device composition, external system integration, driver creation, registry YAML, deck setup, and graph file configuration. Use when the user wants to add/integrate a new workstation, create a workstation driver, configure a station with sub-devices, set up deck and materials, or mentions 工作站/工站/station/workstation.
|
||||
---
|
||||
|
||||
# Uni-Lab-OS 工作站接入指南
|
||||
|
||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统(PLR Deck)和工作流引擎。本指南覆盖从需求分析到验证的全流程。
|
||||
|
||||
> **前置知识**:工作站接入基于 `docs/ai_guides/add_device.md` 的通用设备接入框架,但有显著差异。阅读本指南前无需先读通用指南。
|
||||
|
||||
## 第一步:确定工作站类型
|
||||
|
||||
向用户确认以下信息:
|
||||
|
||||
**Q1: 工作站的业务场景?**
|
||||
|
||||
| 类型 | 基类 | 适用场景 | 示例 |
|
||||
|------|------|----------|------|
|
||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(过滤、转移、加热等) | FilterProtocolStation |
|
||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 系统对接,有专属 API | BioyondStation |
|
||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件,无外部系统 | CoinCellAssembly |
|
||||
|
||||
**Q2: 工作站英文名称?**(如 `my_reaction_station`)
|
||||
|
||||
**Q3: 与外部系统的交互方式?**
|
||||
|
||||
| 方式 | 适用场景 | 需要的配置 |
|
||||
|------|----------|-----------|
|
||||
| 无外部系统 | Protocol 工作站、纯硬件控制 | 无 |
|
||||
| HTTP API | LIMS/MES 系统(如 Bioyond) | `api_host`, `api_key` |
|
||||
| Modbus TCP | PLC 控制 | `address`, `port` |
|
||||
| OPC UA | 工业设备 | `url` |
|
||||
|
||||
**Q4: 子设备组成?**
|
||||
- 列出所有子设备(如反应器、泵、阀、传感器等)
|
||||
- 哪些是已有设备类型?哪些需要新增?
|
||||
- 子设备之间的硬件代理关系(如泵通过串口设备通信)
|
||||
|
||||
**Q5: 物料管理需求?**
|
||||
- 是否需要 Deck(物料面板)?
|
||||
- 物料类型(plate、tip_rack、bottle 等)
|
||||
- 是否需要与外部物料系统同步?
|
||||
|
||||
---
|
||||
|
||||
## 第二步:理解工作站架构
|
||||
|
||||
工作站与普通设备的核心差异:
|
||||
|
||||
| 维度 | 普通设备 | 工作站 |
|
||||
|------|---------|--------|
|
||||
| 基类 | 无(纯 Python 类) | `WorkstationBase` 或 `ProtocolNode` |
|
||||
| ROS 节点 | `BaseROS2DeviceNode` | `ROS2WorkstationNode` |
|
||||
| 状态管理 | `self.data` 字典 | 通常不用 `self.data`,用 `@property` 直接访问 |
|
||||
| 子设备 | 无 | `children` 列表,通过 `self._children` 访问 |
|
||||
| 物料 | 无 | `self.deck`(PLR Deck) |
|
||||
| 图文件角色 | `parent: null` 或 `parent: "<station>"` | `parent: null`,含 `children` 和 `deck` |
|
||||
|
||||
### 继承体系
|
||||
|
||||
`WorkstationBase` (ABC) → `ProtocolNode` (通用协议) / `BioyondWorkstation` (→ ReactionStation, DispensingStation) / `CoinCellAssemblyWorkstation` (硬件控制)
|
||||
|
||||
### ROS 层
|
||||
|
||||
`ROS2WorkstationNode` 额外负责:初始化 children 子设备节点、为子设备创建 ActionClient、配置硬件代理、为 protocol_type 创建协议 ActionServer。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:创建驱动文件
|
||||
|
||||
文件路径:`unilabos/devices/workstation/<station_name>/<station_name>.py`
|
||||
|
||||
### 模板 A:基于外部系统的工作站
|
||||
|
||||
适用于与 LIMS/MES 等外部系统对接的场景。
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
"""工作站描述"""
|
||||
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict = None,
|
||||
deck: Optional[Deck] = None,
|
||||
protocol_type: list = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"MyWorkstation")
|
||||
|
||||
# 外部系统连接配置
|
||||
self.api_host = self.config.get("api_host", "")
|
||||
self.api_key = self.config.get("api_key", "")
|
||||
|
||||
# 工作站业务状态(不同于 self.data 模式)
|
||||
self._status = "Idle"
|
||||
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode") -> None:
|
||||
super().post_init(ros_node)
|
||||
# 在这里启动后台服务、连接监控等
|
||||
|
||||
# ============ 子设备访问 ============
|
||||
|
||||
def _get_child_device(self, device_id: str):
|
||||
"""通过 ID 获取子设备节点"""
|
||||
return self._children.get(device_id)
|
||||
|
||||
# ============ 动作方法 ============
|
||||
|
||||
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
||||
"""启动调度器"""
|
||||
return {"success": True}
|
||||
|
||||
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
||||
"""创建工单"""
|
||||
return {"success": True}
|
||||
|
||||
# ============ 属性 ============
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
return "[]"
|
||||
|
||||
@property
|
||||
def material_info(self) -> str:
|
||||
return "{}"
|
||||
```
|
||||
|
||||
### 模板 B:基于硬件控制的工作站
|
||||
|
||||
适用于直接与 PLC/硬件通信的场景。
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
class MyHardwareWorkstation(WorkstationBase):
|
||||
"""硬件控制工作站"""
|
||||
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict = None,
|
||||
deck: Optional[Deck] = None,
|
||||
address: str = "192.168.1.100",
|
||||
port: str = "502",
|
||||
debug_mode: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
self.config = config or {}
|
||||
self.address = address
|
||||
self.port = int(port)
|
||||
self.debug_mode = debug_mode
|
||||
self.logger = logging.getLogger("MyHardwareWorkstation")
|
||||
|
||||
# 初始化通信客户端
|
||||
if not debug_mode:
|
||||
from unilabos.device_comms.modbus_plc.client import ModbusTcpClient
|
||||
self.client = ModbusTcpClient(host=self.address, port=self.port)
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode") -> None:
|
||||
super().post_init(ros_node)
|
||||
|
||||
# ============ 硬件读写 ============
|
||||
|
||||
def _read_register(self, name: str):
|
||||
"""读取 Modbus 寄存器"""
|
||||
if self.debug_mode:
|
||||
return 0
|
||||
# 实际读取逻辑
|
||||
pass
|
||||
|
||||
# ============ 动作方法 ============
|
||||
|
||||
async def start_process(self, **kwargs) -> Dict[str, Any]:
|
||||
"""启动加工流程"""
|
||||
return {"success": True}
|
||||
|
||||
async def stop_process(self, **kwargs) -> Dict[str, Any]:
|
||||
"""停止加工流程"""
|
||||
return {"success": True}
|
||||
|
||||
# ============ 属性(从硬件实时读取)============
|
||||
|
||||
@property
|
||||
def sys_status(self) -> str:
|
||||
return str(self._read_register("SYS_STATUS"))
|
||||
```
|
||||
|
||||
### 模板 C:Protocol 工作站
|
||||
|
||||
适用于标准化学操作协议的场景,直接使用 `ProtocolNode`。
|
||||
|
||||
```python
|
||||
from typing import List, Optional
|
||||
from pylabrobot.resources import Resource as PLRResource
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
||||
|
||||
|
||||
class MyProtocolStation(ProtocolNode):
|
||||
"""Protocol 工作站 — 使用标准化学操作协议"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
protocol_type: List[str],
|
||||
deck: Optional[PLRResource] = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(protocol_type=protocol_type, deck=deck, *args, **kwargs)
|
||||
```
|
||||
|
||||
> Protocol 工作站通常不需要自定义驱动类,直接使用 `ProtocolNode` 并在注册表和图文件中配置 `protocol_type` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 第四步:创建子设备驱动(如需要)
|
||||
|
||||
工作站的子设备本身是独立设备。按 `docs/ai_guides/add_device.md` 的标准流程创建。
|
||||
|
||||
子设备的关键约束:
|
||||
- 在图文件中 `parent` 指向工作站 ID
|
||||
- 图文件中在工作站的 `children` 数组里列出
|
||||
- 如需硬件代理,在子设备的 `config.hardware_interface.name` 指向通信设备 ID
|
||||
|
||||
---
|
||||
|
||||
## 第五步:创建注册表 YAML
|
||||
|
||||
路径:`unilabos/registry/devices/<station_name>.yaml`
|
||||
|
||||
### 最小配置
|
||||
|
||||
```yaml
|
||||
my_workstation:
|
||||
category:
|
||||
- workstation
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_station:MyWorkstation
|
||||
type: python
|
||||
```
|
||||
|
||||
启动时 `--complete_registry` 自动补全 `status_types` 和 `action_value_mappings`。
|
||||
|
||||
### 完整配置参考
|
||||
|
||||
```yaml
|
||||
my_workstation:
|
||||
description: "我的工作站"
|
||||
version: "1.0.0"
|
||||
category:
|
||||
- workstation
|
||||
- my_category
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_station:MyWorkstation
|
||||
type: python
|
||||
status_types:
|
||||
workflow_sequence: String
|
||||
material_info: String
|
||||
action_value_mappings:
|
||||
scheduler_start:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
result:
|
||||
success: success
|
||||
create_order:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal:
|
||||
json_str: json_str
|
||||
result:
|
||||
success: success
|
||||
init_param_schema:
|
||||
config:
|
||||
type: object
|
||||
deck:
|
||||
type: object
|
||||
protocol_type:
|
||||
type: array
|
||||
```
|
||||
|
||||
### 子设备注册表
|
||||
|
||||
子设备有独立的注册表文件,需要在 `category` 中包含工作站标识:
|
||||
|
||||
```yaml
|
||||
my_reactor:
|
||||
category:
|
||||
- reactor
|
||||
- my_workstation
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_reactor:MyReactor
|
||||
type: python
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:配置 Deck 资源(如需要)
|
||||
|
||||
如果工作站有物料管理需求,需要定义 Deck 类。
|
||||
|
||||
### 使用已有 Deck 类
|
||||
|
||||
查看 `unilabos/resources/` 目录下是否有适用的 Deck 类。
|
||||
|
||||
### 创建自定义 Deck
|
||||
|
||||
在 `unilabos/resources/<category>/decks.py` 中定义:
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck
|
||||
from pylabrobot.resources.coordinate import Coordinate
|
||||
|
||||
|
||||
def MyStation_Deck(name: str = "MyStation_Deck") -> Deck:
|
||||
deck = Deck(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
|
||||
# 在 deck 上定义子资源位置(carrier、plate 等)
|
||||
return deck
|
||||
```
|
||||
|
||||
在 `unilabos/resources/<category>/` 下注册或通过注册表引用。
|
||||
|
||||
---
|
||||
|
||||
## 第七步:配置图文件
|
||||
|
||||
图文件路径:`unilabos/test/experiments/<station_name>.json`
|
||||
|
||||
### 完整结构
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"name": "my_station",
|
||||
"children": ["my_deck", "sub_device_1", "sub_device_2"],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "my_workstation",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_KEY"
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_module.decks:MyStation_Deck"
|
||||
}
|
||||
},
|
||||
"size_x": 2700.0,
|
||||
"size_y": 1080.0,
|
||||
"size_z": 1500.0,
|
||||
"protocol_type": [],
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"name": "my_deck",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "MyStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "sub_device_1",
|
||||
"name": "sub_device_1",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "device",
|
||||
"class": "sub_device_registry_name",
|
||||
"position": {"x": 100, "y": 0, "z": 0},
|
||||
"config": {},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 图文件规则
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 节点唯一标识,与 `children` 数组中的引用一致 |
|
||||
| `children` | 包含 deck ID 和所有子设备 ID |
|
||||
| `parent` | 工作站节点为 `null`;子设备/deck 指向工作站 ID |
|
||||
| `type` | 工作站和子设备为 `"device"`;deck 为 `"deck"` |
|
||||
| `class` | 对应注册表中的设备名 |
|
||||
| `deck.data._resource_child_name` | 必须与 deck 节点的 `id` 一致 |
|
||||
| `deck.data._resource_type` | Deck 工厂函数的完整 Python 路径 |
|
||||
| `protocol_type` | Protocol 工作站填入协议名列表;否则为 `[]` |
|
||||
| `config` | 传入驱动 `__init__` 的 `config` 参数 |
|
||||
|
||||
---
|
||||
|
||||
## 第八步:验证
|
||||
|
||||
```bash
|
||||
# 1. 模块可导入
|
||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
||||
|
||||
# 2. 注册表补全
|
||||
unilab -g <graph>.json --complete_registry
|
||||
|
||||
# 3. 启动测试
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现外部系统对接型工作站时,详见 [reference.md](reference.md):RPC 客户端、HTTP 回调服务、连接监控、Config 结构模式(material_type_mappings / warehouse_mapping / workflow_mappings)、ResourceSynchronizer、update_resource、工作流序列、站间物料转移、post_init 完整模式。
|
||||
|
||||
---
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.__init__` 需要 `deck` 参数
|
||||
2. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
||||
3. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
||||
4. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
5. **子设备在图文件中声明** — 不在驱动代码中创建子设备实例
|
||||
6. **`deck` 配置中的 `_resource_child_name` 必须与 deck 节点 ID 一致**
|
||||
7. **Protocol 工作站优先使用 `ProtocolNode`** — 不需要自定义类
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
工作站接入进度:
|
||||
- [ ] 1. 确定工作站类型(Protocol / 外部系统 / 硬件控制)
|
||||
- [ ] 2. 确认子设备组成和物料需求
|
||||
- [ ] 3. 创建工作站驱动 unilabos/devices/workstation/<name>/<name>.py
|
||||
- [ ] 4. 创建子设备驱动(如需要,按 add_device.md 流程)
|
||||
- [ ] 5. 创建注册表 unilabos/registry/devices/<name>.yaml
|
||||
- [ ] 6. 创建/选择 Deck 资源类(如需要)
|
||||
- [ ] 7. 配置图文件 unilabos/test/experiments/<name>.json
|
||||
- [ ] 8. 验证:可导入 + 注册表补全 + 启动测试
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 现有工作站参考
|
||||
|
||||
| 工作站 | 注册表名 | 驱动类 | 类型 |
|
||||
|--------|----------|--------|------|
|
||||
| Protocol 通用 | `workstation` | `ProtocolNode` | Protocol |
|
||||
| Bioyond 反应站 | `reaction_station.bioyond` | `BioyondReactionStation` | 外部系统 |
|
||||
| Bioyond 配液站 | `bioyond_dispensing_station` | `BioyondDispensingStation` | 外部系统 |
|
||||
| 纽扣电池组装 | `coincellassemblyworkstation_device` | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
||||
|
||||
### 参考文件路径
|
||||
|
||||
- 基类: `unilabos/devices/workstation/workstation_base.py`
|
||||
- Bioyond 基类: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||
- 反应站: `unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py`
|
||||
- 配液站: `unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py`
|
||||
- 纽扣电池: `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||
- ROS 节点: `unilabos/ros/nodes/presets/workstation.py`
|
||||
- 图文件: `unilabos/test/experiments/reaction_station_bioyond.json`, `dispensing_station_bioyond.json`
|
||||
371
.cursor/skills/add-workstation/reference.md
Normal file
371
.cursor/skills/add-workstation/reference.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 工作站高级模式参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
||||
Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 外部系统集成模式
|
||||
|
||||
### 1.1 RPC 客户端
|
||||
|
||||
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
||||
|
||||
```python
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
|
||||
|
||||
class MySystemRPC(BaseRequest):
|
||||
"""外部系统 RPC 客户端"""
|
||||
|
||||
def __init__(self, host: str, api_key: str):
|
||||
super().__init__(host)
|
||||
self.api_key = api_key
|
||||
|
||||
def _request(self, endpoint: str, data: dict = None) -> dict:
|
||||
return self.post(
|
||||
url=f"{self.host}/api/{endpoint}",
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": data or {},
|
||||
},
|
||||
)
|
||||
|
||||
def query_status(self) -> dict:
|
||||
return self._request("status/query")
|
||||
|
||||
def create_order(self, order_data: dict) -> dict:
|
||||
return self._request("order/create", order_data)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
||||
|
||||
### 1.2 HTTP 回调服务
|
||||
|
||||
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, config=None, deck=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
http_cfg = self.config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_cfg.get("http_service_port", 8080),
|
||||
}
|
||||
self.http_service = None
|
||||
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
```
|
||||
|
||||
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
||||
|
||||
| 端点 | 调用的工作站方法 |
|
||||
|------|-----------------|
|
||||
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
||||
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
||||
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
||||
| `/report/material_change` | `process_material_change_report(report_data)` |
|
||||
| `/report/error_handling` | `handle_external_error(error_data)` |
|
||||
|
||||
实现对应方法即可接收回调:
|
||||
|
||||
```python
|
||||
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
||||
"""处理步骤完成报告"""
|
||||
step_name = report_request.data.get("stepName")
|
||||
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
||||
"""处理订单完成报告"""
|
||||
order_code = report_request.data.get("orderCode")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
||||
|
||||
### 1.3 连接监控
|
||||
|
||||
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
||||
|
||||
```python
|
||||
class ConnectionMonitor:
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 调用外部系统接口检测连接
|
||||
self.workstation.hardware_interface.ping()
|
||||
status = "online"
|
||||
except Exception:
|
||||
status = "offline"
|
||||
time.sleep(self.check_interval)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Config 结构模式
|
||||
|
||||
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
||||
|
||||
### 2.1 外部系统连接
|
||||
|
||||
```json
|
||||
{
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 HTTP 回调服务
|
||||
|
||||
```json
|
||||
{
|
||||
"http_service_config": {
|
||||
"http_service_host": "127.0.0.1",
|
||||
"http_service_port": 8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 物料类型映射
|
||||
|
||||
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"material_type_mappings": {
|
||||
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 仓库映射
|
||||
|
||||
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"仓库名": {
|
||||
"uuid": "warehouse-uuid",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-A01",
|
||||
"A02": "site-uuid-A02"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 工作流映射
|
||||
|
||||
将内部工作流名映射到外部系统的工作流 ID。
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow_mappings": {
|
||||
"internal_workflow_name": "external-workflow-uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 物料默认参数
|
||||
|
||||
```json
|
||||
{
|
||||
"material_default_parameters": {
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 资源同步机制
|
||||
|
||||
### 3.1 ResourceSynchronizer
|
||||
|
||||
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def __init__(self, workstation, api_client):
|
||||
super().__init__(workstation)
|
||||
self.api_client = api_client
|
||||
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统拉取物料到 deck"""
|
||||
external_materials = self.api_client.list_materials()
|
||||
for material in external_materials:
|
||||
plr_resource = self._convert_to_plr(material)
|
||||
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
||||
return True
|
||||
|
||||
def sync_to_external(self, plr_resource) -> bool:
|
||||
"""将 deck 中的物料变更推送到外部系统"""
|
||||
external_data = self._convert_from_plr(plr_resource)
|
||||
self.api_client.update_material(external_data)
|
||||
return True
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统推送的物料变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
### 3.2 update_resource — 上传资源树到云端
|
||||
|
||||
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
||||
|
||||
```python
|
||||
# 在 post_init 中上传初始 deck
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 在动作方法中更新特定资源
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [updated_plate]}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 工作流序列管理
|
||||
|
||||
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._workflow_sequence = []
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
"""返回 JSON 字符串,ROS 自动发布"""
|
||||
import json
|
||||
return json.dumps(self._workflow_sequence)
|
||||
|
||||
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
||||
"""添加工作流到队列"""
|
||||
self._workflow_sequence.append({
|
||||
"name": workflow_name,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
})
|
||||
return {"success": True}
|
||||
|
||||
async def clear_workflows(self) -> Dict[str, Any]:
|
||||
"""清空工作流队列"""
|
||||
self._workflow_sequence = []
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 站间物料转移
|
||||
|
||||
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
||||
|
||||
```python
|
||||
async def transfer_materials_to_another_station(
|
||||
self,
|
||||
target_device_id: str,
|
||||
transfer_groups: list,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""将物料转移到另一个工作站"""
|
||||
target_node = self._children.get(target_device_id)
|
||||
if not target_node:
|
||||
# 通过 ROS 节点查找非子设备的目标站
|
||||
pass
|
||||
|
||||
for group in transfer_groups:
|
||||
resource = self.find_resource_by_name(group["resource_name"])
|
||||
# 从本站 deck 移除
|
||||
resource.unassign()
|
||||
# 调用目标站的接收方法
|
||||
# ...
|
||||
|
||||
return {"success": True, "transferred": len(transfer_groups)}
|
||||
```
|
||||
|
||||
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
||||
|
||||
---
|
||||
|
||||
## 6. post_init 完整模式
|
||||
|
||||
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
||||
|
||||
```python
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
|
||||
# 1. 初始化外部系统客户端(此时 config 已可用)
|
||||
self.rpc_client = MySystemRPC(
|
||||
host=self.config.get("api_host"),
|
||||
api_key=self.config.get("api_key"),
|
||||
)
|
||||
self.hardware_interface = self.rpc_client
|
||||
|
||||
# 2. 启动连接监控
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
|
||||
# 3. 启动 HTTP 回调服务
|
||||
if hasattr(self, '_http_service_config'):
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
|
||||
# 4. 上传 deck 到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 5. 初始化资源同步器(可选)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
||||
```
|
||||
381
.cursor/skills/edit-experiment-graph/SKILL.md
Normal file
381
.cursor/skills/edit-experiment-graph/SKILL.md
Normal file
@@ -0,0 +1,381 @@
|
||||
---
|
||||
name: edit-experiment-graph
|
||||
description: Guide for creating and editing experiment graph files in Uni-Lab-OS (创建/编辑实验组态图). Covers node types, link types, parent-child relationships, deck configuration, and common graph patterns. Use when the user wants to create a graph file, edit an experiment configuration, set up device topology, or mentions 图文件/graph/组态/拓扑/实验图/experiment JSON.
|
||||
---
|
||||
|
||||
# 创建/编辑实验组态图
|
||||
|
||||
实验图(Graph File)定义设备拓扑、物理连接和物料配置。系统启动时加载图文件,初始化所有设备和连接关系。
|
||||
|
||||
路径:`unilabos/test/experiments/<name>.json`
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认需求
|
||||
|
||||
向用户确认:
|
||||
|
||||
| 信息 | 说明 |
|
||||
|------|------|
|
||||
| 场景类型 | 单设备调试 / 多设备联调 / 工作站完整图 |
|
||||
| 包含的设备 | 设备 ID、注册表 class 名、配置参数 |
|
||||
| 连接关系 | 物理连接(管道)/ 通信连接(串口)/ 无连接 |
|
||||
| 父子关系 | 是否有工作站包含子设备 |
|
||||
| 物料需求 | 是否需要 Deck、容器、试剂瓶 |
|
||||
|
||||
---
|
||||
|
||||
## 第二步:JSON 顶层结构
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
> `links` 也可写作 `edges`,加载时两者等效。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:定义 Nodes
|
||||
|
||||
### 节点字段
|
||||
|
||||
| 字段 | 类型 | 必需 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `id` | string | **是** | — | 节点唯一标识,links 和 children 中引用此值 |
|
||||
| `class` | string | **是** | — | 对应注册表名(设备/资源 YAML 的 key),容器可为 `null` |
|
||||
| `name` | string | 否 | 同 `id` | 显示名称,缺省时自动用 `id` |
|
||||
| `type` | string | 否 | `"device"` | 节点类型(见下表),缺省时自动设为 `"device"` |
|
||||
| `children` | string[] | 否 | `[]` | 子节点 ID 列表 |
|
||||
| `parent` | string\|null | 否 | `null` | 父节点 ID,顶层设备为 `null` |
|
||||
| `position` | object | 否 | `{x:0,y:0,z:0}` | 空间坐标 |
|
||||
| `config` | object | 否 | `{}` | 传给驱动 `__init__` 的参数 |
|
||||
| `data` | object | 否 | `{}` | 初始运行状态 |
|
||||
| `size_x/y/z` | float | 否 | — | 节点物理尺寸(工作站节点常用) |
|
||||
|
||||
> 非标准字段(如 `api_host`)会自动移入 `config`。
|
||||
|
||||
### 节点类型
|
||||
|
||||
| `type` | 用途 | `class` 要求 |
|
||||
|--------|------|-------------|
|
||||
| `device` | 设备(默认) | 注册表中的设备名 |
|
||||
| `deck` | 工作台面 | Deck 工厂函数/类名 |
|
||||
| `container` | 容器(烧瓶、反应釜) | `null` 或具体容器类名 |
|
||||
|
||||
### 设备节点模板
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_device",
|
||||
"name": "我的设备",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "registry_device_name",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"port": "/dev/ttyUSB0",
|
||||
"baudrate": 115200
|
||||
},
|
||||
"data": {
|
||||
"status": "Idle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 容器节点模板
|
||||
|
||||
容器用于协议系统中表示试剂瓶、反应釜等,`class` 通常为 `null`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "flask_DMF",
|
||||
"name": "DMF试剂瓶",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {"x": 200, "y": 500, "z": 0},
|
||||
"config": {"max_volume": 1000.0},
|
||||
"data": {
|
||||
"liquid": [{"liquid_type": "DMF", "liquid_volume": 800.0}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deck 节点模板
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_deck",
|
||||
"name": "my_deck",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "MyStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第四步:定义 Links
|
||||
|
||||
### Link 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `source` | string | 源节点 ID |
|
||||
| `target` | string | 目标节点 ID |
|
||||
| `type` | string | `"physical"` / `"fluid"` / `"communication"` |
|
||||
| `port` | object | 端口映射 `{source_id: "port_name", target_id: "port_name"}` |
|
||||
|
||||
### 物理/流体连接
|
||||
|
||||
设备间的管道连接,协议系统用此查找路径:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "multiway_valve_1",
|
||||
"target": "flask_DMF",
|
||||
"type": "fluid",
|
||||
"port": {
|
||||
"multiway_valve_1": "2",
|
||||
"flask_DMF": "outlet"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 通信连接
|
||||
|
||||
设备间的串口/IO 通信代理,加载时自动将端口信息写入目标设备 config:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "serial_device",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_1": "port",
|
||||
"serial_device": "port"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第五步:父子关系与工作站配置
|
||||
|
||||
### 工作站 + 子设备
|
||||
|
||||
工作站节点的 `children` 列出所有子节点 ID,子节点的 `parent` 指向工作站:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck", "pump_1", "valve_1", "reactor_1"],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"config": {
|
||||
"protocol_type": ["PumpTransferProtocol", "CleanProtocol"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 工作站 + Deck 引用
|
||||
|
||||
工作站节点中通过 `deck` 字段引用 Deck:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck", "sub_device_1"],
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_module.decks:MyDeck"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键约束:**
|
||||
- `_resource_child_name` 必须与 Deck 节点的 `id` 一致
|
||||
- `_resource_type` 为 Deck 类/工厂函数的完整 Python 路径
|
||||
|
||||
---
|
||||
|
||||
## 常见图模式
|
||||
|
||||
### 模式 A:单设备调试
|
||||
|
||||
最简形式,一个设备节点,无连接:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_device",
|
||||
"name": "my_device",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "motor.zdt_x42",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {"port": "/dev/ttyUSB0", "baudrate": 115200},
|
||||
"data": {"status": "idle"}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 B:Protocol 工作站(泵+阀+容器)
|
||||
|
||||
工作站配合泵、阀、容器和物理连接,用于协议编译:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "station", "name": "协议工作站",
|
||||
"class": "workstation", "type": "device", "parent": null,
|
||||
"children": ["pump", "valve", "flask_solvent", "reactor", "waste"],
|
||||
"config": {"protocol_type": ["PumpTransferProtocol"]}
|
||||
},
|
||||
{"id": "pump", "name": "转移泵", "class": "virtual_transfer_pump",
|
||||
"type": "device", "parent": "station",
|
||||
"config": {"port": "VIRTUAL", "max_volume": 25.0},
|
||||
"data": {"status": "Idle", "position": 0.0, "valve_position": "0"}},
|
||||
{"id": "valve", "name": "多通阀", "class": "virtual_multiway_valve",
|
||||
"type": "device", "parent": "station",
|
||||
"config": {"port": "VIRTUAL", "positions": 8}},
|
||||
{"id": "flask_solvent", "name": "溶剂瓶", "type": "container",
|
||||
"class": null, "parent": "station",
|
||||
"config": {"max_volume": 1000.0},
|
||||
"data": {"liquid": [{"liquid_type": "DMF", "liquid_volume": 500}]}},
|
||||
{"id": "reactor", "name": "反应器", "type": "container",
|
||||
"class": null, "parent": "station"},
|
||||
{"id": "waste", "name": "废液瓶", "type": "container",
|
||||
"class": null, "parent": "station"}
|
||||
],
|
||||
"links": [
|
||||
{"source": "pump", "target": "valve", "type": "fluid",
|
||||
"port": {"pump": "transferpump", "valve": "transferpump"}},
|
||||
{"source": "valve", "target": "flask_solvent", "type": "fluid",
|
||||
"port": {"valve": "1", "flask_solvent": "outlet"}},
|
||||
{"source": "valve", "target": "reactor", "type": "fluid",
|
||||
"port": {"valve": "2", "reactor": "inlet"}},
|
||||
{"source": "valve", "target": "waste", "type": "fluid",
|
||||
"port": {"valve": "3", "waste": "inlet"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 C:外部系统工作站 + Deck
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_station", "class": "reaction_station.bioyond",
|
||||
"parent": null, "children": ["bioyond_deck"],
|
||||
"config": {
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_KEY",
|
||||
"material_type_mappings": {},
|
||||
"warehouse_mapping": {}
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "bioyond_deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bioyond_deck", "class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"parent": "bioyond_station", "type": "deck",
|
||||
"config": {"type": "BIOYOND_PolymerReactionStation_Deck", "setup": true}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 D:通信代理(串口设备)
|
||||
|
||||
泵通过串口设备通信,使用 `communication` 类型的 link。加载时系统会自动将串口端口信息写入泵的 `config`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "station", "name": "工作站", "type": "device",
|
||||
"class": "workstation", "parent": null,
|
||||
"children": ["serial_1", "pump_1"]},
|
||||
{"id": "serial_1", "name": "串口", "type": "device",
|
||||
"class": "serial", "parent": "station",
|
||||
"config": {"port": "COM7", "baudrate": 9600}},
|
||||
{"id": "pump_1", "name": "注射泵", "type": "device",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08", "parent": "station"}
|
||||
],
|
||||
"links": [
|
||||
{"source": "pump_1", "target": "serial_1", "type": "communication",
|
||||
"port": {"pump_1": "port", "serial_1": "port"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 启动测试
|
||||
unilab -g unilabos/test/experiments/<name>.json --complete_registry
|
||||
|
||||
# 仅检查注册表
|
||||
python -m unilabos --check_mode --skip_env_check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
处理复杂图文件时,详见 [reference.md](reference.md):ResourceDict 完整字段 schema、Pose 标准化规则、Handle 验证机制、GraphML 格式支持、外部系统工作站完整 config 结构。
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误 | 原因 | 修复 |
|
||||
|------|------|------|
|
||||
| `class` 找不到 | 注册表中无此设备名 | 在 `unilabos/registry/devices/` 或 `resources/` 中搜索正确名称 |
|
||||
| children/parent 不一致 | 子节点 `parent` 与父节点 `children` 不匹配 | 确保双向一致 |
|
||||
| `_resource_child_name` 不匹配 | Deck 引用名与 Deck 节点 `id` 不同 | 保持一致 |
|
||||
| Link 端口错误 | `port` 中的 key 不是 source/target 的 `id` | key 必须是对应节点的 `id` |
|
||||
| 重复 UUID | 多个节点有相同 `uuid` | 删除或修改 UUID |
|
||||
|
||||
---
|
||||
|
||||
## 参考路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| 图文件目录 | `unilabos/test/experiments/` |
|
||||
| 协议测试站 | `unilabos/test/experiments/Protocol_Test_Station/` |
|
||||
| 图加载代码 | `unilabos/resources/graphio.py` |
|
||||
| 节点模型 | `unilabos/resources/resource_tracker.py` |
|
||||
| 设备注册表 | `unilabos/registry/devices/` |
|
||||
| 资源注册表 | `unilabos/registry/resources/` |
|
||||
| 用户文档 | `docs/user_guide/graph_files.md` |
|
||||
255
.cursor/skills/edit-experiment-graph/reference.md
Normal file
255
.cursor/skills/edit-experiment-graph/reference.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 实验图高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含 ResourceDict 完整 schema、Handle 验证、GraphML 格式、Pose 标准化规则和复杂图文件结构。Agent 在需要处理这些场景时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. ResourceDict 完整字段
|
||||
|
||||
`unilabos/resources/resource_tracker.py` 中定义的节点数据模型:
|
||||
|
||||
| 字段 | 类型 | 别名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | `str` | — | 节点唯一标识 |
|
||||
| `uuid` | `str` | — | 全局唯一标识 |
|
||||
| `name` | `str` | — | 显示名称 |
|
||||
| `description` | `str` | — | 描述(默认 `""` ) |
|
||||
| `resource_schema` | `Dict[str, Any]` | `schema` | 资源 schema |
|
||||
| `model` | `Dict[str, Any]` | — | 3D 模型信息 |
|
||||
| `icon` | `str` | — | 图标(默认 `""` ) |
|
||||
| `parent_uuid` | `Optional[str]` | — | 父节点 UUID |
|
||||
| `parent` | `Optional[ResourceDict]` | — | 父节点引用(序列化时 exclude) |
|
||||
| `type` | `Union[Literal["device"], str]` | — | 节点类型 |
|
||||
| `klass` | `str` | `class` | 注册表类名 |
|
||||
| `pose` | `ResourceDictPosition` | — | 位姿信息 |
|
||||
| `config` | `Dict[str, Any]` | — | 配置参数 |
|
||||
| `data` | `Dict[str, Any]` | — | 运行时数据 |
|
||||
| `extra` | `Dict[str, Any]` | — | 扩展数据 |
|
||||
|
||||
### Pose 完整结构(ResourceDictPosition)
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `size` | `{width, height, depth}` | `{0,0,0}` | 节点尺寸 |
|
||||
| `scale` | `{x, y, z}` | `{1,1,1}` | 缩放比例 |
|
||||
| `layout` | `"2d"/"x-y"/"z-y"/"x-z"` | `"x-y"` | 布局方向 |
|
||||
| `position` | `{x, y, z}` | `{0,0,0}` | 2D 位置 |
|
||||
| `position3d` | `{x, y, z}` | `{0,0,0}` | 3D 位置 |
|
||||
| `rotation` | `{x, y, z}` | `{0,0,0}` | 旋转角度 |
|
||||
| `cross_section_type` | `"rectangle"/"circle"/"rounded_rectangle"` | `"rectangle"` | 横截面形状 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Position / Pose 标准化规则
|
||||
|
||||
图文件中的 `position` 有多种写法,加载时自动标准化。
|
||||
|
||||
### 输入格式兼容
|
||||
|
||||
```json
|
||||
// 格式 A: 直接 {x, y, z}(最常用)
|
||||
"position": {"x": 100, "y": 200, "z": 0}
|
||||
|
||||
// 格式 B: 嵌套 position
|
||||
"position": {"position": {"x": 100, "y": 200, "z": 0}}
|
||||
|
||||
// 格式 C: 使用 pose 字段
|
||||
"pose": {"position": {"x": 100, "y": 200, "z": 0}}
|
||||
|
||||
// 格式 D: 顶层 x, y, z(无 position 字段)
|
||||
"x": 100, "y": 200, "z": 0
|
||||
```
|
||||
|
||||
### 标准化流程
|
||||
|
||||
1. **graphio.py `canonicalize_nodes_data`**:若 `position` 不是 dict,从节点顶层提取 `x/y/z` 填入 `pose.position`
|
||||
2. **resource_tracker.py `get_resource_instance_from_dict`**:若 `position.x` 存在(旧格式),转为 `{"position": {"x":..., "y":..., "z":...}}`
|
||||
3. `pose.size` 从 `config.size_x/size_y/size_z` 自动填充
|
||||
|
||||
---
|
||||
|
||||
## 3. Handle 验证
|
||||
|
||||
启动时系统验证 link 中的 `sourceHandle` / `targetHandle` 是否在注册表的 `handles` 中定义。
|
||||
|
||||
```python
|
||||
# unilabos/app/main.py (约 449-481 行)
|
||||
source_handler_keys = [
|
||||
h["handler_key"] for h in materials[source_node.klass]["handles"]
|
||||
if h["io_type"] == "source"
|
||||
]
|
||||
target_handler_keys = [
|
||||
h["handler_key"] for h in materials[target_node.klass]["handles"]
|
||||
if h["io_type"] == "target"
|
||||
]
|
||||
if source_handle not in source_handler_keys:
|
||||
print_status(f"节点 {source_node.id} 的source端点 {source_handle} 不存在", "error")
|
||||
resource_edge_info.pop(...) # 移除非法 link
|
||||
```
|
||||
|
||||
**Handle 定义在注册表 YAML 中:**
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
handles:
|
||||
- handler_key: access
|
||||
io_type: target
|
||||
data_type: fluid
|
||||
side: NORTH
|
||||
label: access
|
||||
```
|
||||
|
||||
> 大多数简单设备不定义 handles,此验证仅对有 `sourceHandle`/`targetHandle` 的 link 生效。
|
||||
|
||||
---
|
||||
|
||||
## 4. GraphML 格式支持
|
||||
|
||||
除 JSON 外,系统也支持 GraphML 格式(`unilabos/resources/graphio.py::read_graphml`)。
|
||||
|
||||
### 与 JSON 的关键差异
|
||||
|
||||
| 特性 | JSON | GraphML |
|
||||
|------|------|---------|
|
||||
| 父子关系 | `parent`/`children` 字段 | `::` 分隔的节点 ID(如 `station::pump_1`) |
|
||||
| 加载后 | 直接解析 | 先 `nx.read_graphml` 再转 JSON 格式 |
|
||||
| 输出 | 不生成副本 | 自动生成等价的 `.json` 文件 |
|
||||
|
||||
### GraphML 转换流程
|
||||
|
||||
```
|
||||
nx.read_graphml(file)
|
||||
↓ 用 label 重映射节点名
|
||||
↓ 从 "::" 推断 parent_relation
|
||||
nx.relabel_nodes + nx.node_link_data
|
||||
↓ canonicalize_nodes_data + canonicalize_links_ports
|
||||
↓ 写出等价 JSON 文件
|
||||
physical_setup_graph + handle_communications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 复杂图文件结构示例
|
||||
|
||||
### 外部系统工作站完整 config
|
||||
|
||||
以 `reaction_station_bioyond.json` 为例,工作站 `config` 中的关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://172.21.103.36:45388",
|
||||
|
||||
"workflow_mappings": {
|
||||
"scheduler_start": {"workflow": "start", "params": {}},
|
||||
"create_order": {"workflow": "create_order", "params": {}}
|
||||
},
|
||||
|
||||
"material_type_mappings": {
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "type-uuid-here"],
|
||||
"BIOYOND_PolymerStation_1BottleCarrier": ["试剂瓶", "type-uuid-here"]
|
||||
},
|
||||
|
||||
"warehouse_mapping": {
|
||||
"堆栈1左": {
|
||||
"uuid": "warehouse-uuid-here",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-1",
|
||||
"A02": "site-uuid-2"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"http_service_config": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 45399,
|
||||
"routes": ["/callback/workflow", "/callback/material"]
|
||||
},
|
||||
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
},
|
||||
|
||||
"size_x": 2700.0,
|
||||
"size_y": 1080.0,
|
||||
"size_z": 2500.0,
|
||||
"protocol_type": [],
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 子设备 Reactor 节点
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "reactor_1",
|
||||
"name": "reactor_1",
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "device",
|
||||
"class": "bioyond_reactor",
|
||||
"position": {"x": 1150, "y": 300, "z": 0},
|
||||
"config": {
|
||||
"reactor_index": 0,
|
||||
"bioyond_workflow_key": "reactor_1"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Deck 节点
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "Bioyond_Deck",
|
||||
"name": "Bioyond_Deck",
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "deck",
|
||||
"class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Link 端口标准化
|
||||
|
||||
`graphio.py::canonicalize_links_ports` 处理 `port` 字段的多种格式:
|
||||
|
||||
```python
|
||||
# 输入: 字符串格式 "(A,B)"
|
||||
"port": "(pump_1, valve_1)"
|
||||
# 输出: 字典格式
|
||||
"port": {"source_id": "pump_1", "target_id": "valve_1"}
|
||||
|
||||
# 输入: 已是字典
|
||||
"port": {"pump_1": "port", "serial_1": "port"}
|
||||
# 保持不变
|
||||
|
||||
# 输入: 无 port 字段
|
||||
# 自动补充空 port
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| ResourceDict 模型 | `unilabos/resources/resource_tracker.py` |
|
||||
| 图加载 + 标准化 | `unilabos/resources/graphio.py` |
|
||||
| Handle 验证 | `unilabos/app/main.py` (449-481 行) |
|
||||
| 反应站图文件 | `unilabos/test/experiments/reaction_station_bioyond.json` |
|
||||
| 配液站图文件 | `unilabos/test/experiments/dispensing_station_bioyond.json` |
|
||||
| 用户文档 | `docs/user_guide/graph_files.md` |
|
||||
Reference in New Issue
Block a user