mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-29 10:09:56 +00:00
Compare commits
43 Commits
3e43359460
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e212dc7781 | ||
|
|
96c4be17dc | ||
|
|
44afc7733b | ||
|
|
a34ffcaeb9 | ||
|
|
70c6685283 | ||
|
|
7027bd5ed1 | ||
|
|
57f5c8752d | ||
|
|
827169827a | ||
|
|
c4a2f68649 | ||
|
|
195fad9398 | ||
|
|
898ed5d34b | ||
|
|
60cbedc4b2 | ||
|
|
2d6a9f7db9 | ||
|
|
5dca3d8c3d | ||
|
|
37cbed722a | ||
|
|
132cffbe7c | ||
|
|
36e5ff804c | ||
|
|
eaf8ad5609 | ||
|
|
16122ad2fa | ||
|
|
d3fef85dd8 | ||
|
|
f77ac2a5e8 | ||
|
|
93ac55a65b | ||
|
|
af35debe38 | ||
|
|
d68fc5e380 | ||
|
|
f0ea32f163 | ||
|
|
3c8020813b | ||
|
|
97996d316f | ||
|
|
9815961a1f | ||
|
|
fe501c965f | ||
|
|
92bfb069d5 | ||
|
|
b61c818f7f | ||
|
|
47a29a0c2f | ||
|
|
9c6f7c7505 | ||
|
|
e4e4bfbe20 | ||
|
|
64c748d921 | ||
|
|
15ff0e9d30 | ||
|
|
f8a52860ad | ||
|
|
e30c01d54e | ||
|
|
37ec49f318 | ||
|
|
6bf57f18c1 | ||
|
|
c4a3be1498 | ||
|
|
e11070315d | ||
|
|
50ebcad9d7 |
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos
|
path: ../../unilabos
|
||||||
@@ -54,7 +54,7 @@ requirements:
|
|||||||
- pymodbus
|
- pymodbus
|
||||||
- matplotlib
|
- matplotlib
|
||||||
- pylibftdi
|
- pylibftdi
|
||||||
- uni-lab::unilabos-env ==0.10.19
|
- uni-lab::unilabos-env ==0.11.1
|
||||||
|
|
||||||
about:
|
about:
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-env
|
name: unilabos-env
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-full
|
name: unilabos-full
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
@@ -11,7 +11,7 @@ build:
|
|||||||
requirements:
|
requirements:
|
||||||
run:
|
run:
|
||||||
# Base unilabos package (includes unilabos-env)
|
# Base unilabos package (includes unilabos-env)
|
||||||
- uni-lab::unilabos ==0.10.19
|
- uni-lab::unilabos ==0.11.1
|
||||||
# Documentation tools
|
# Documentation tools
|
||||||
- sphinx
|
- sphinx
|
||||||
- sphinx_rtd_theme
|
- sphinx_rtd_theme
|
||||||
|
|||||||
328
.cursor/rules/device-drivers.mdc
Normal file
328
.cursor/rules/device-drivers.mdc
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
---
|
||||||
|
description: 设备驱动开发规范
|
||||||
|
globs: ["unilabos/devices/**/*.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 设备驱动开发规范
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
unilabos/devices/
|
||||||
|
├── virtual/ # 虚拟设备(用于测试)
|
||||||
|
│ ├── virtual_stirrer.py
|
||||||
|
│ └── virtual_centrifuge.py
|
||||||
|
├── liquid_handling/ # 液体处理设备
|
||||||
|
├── balance/ # 天平设备
|
||||||
|
├── hplc/ # HPLC设备
|
||||||
|
├── pump_and_valve/ # 泵和阀门
|
||||||
|
├── temperature/ # 温度控制设备
|
||||||
|
├── workstation/ # 工作站(组合设备)
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设备类完整模板
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time as time_module
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
|
|
||||||
|
class MyDevice:
|
||||||
|
"""
|
||||||
|
设备类描述
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
device_id: 设备唯一标识
|
||||||
|
config: 设备配置字典
|
||||||
|
data: 设备状态数据
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_id: str = None,
|
||||||
|
config: Dict[str, Any] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化设备
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: 设备ID
|
||||||
|
config: 配置字典
|
||||||
|
**kwargs: 其他参数
|
||||||
|
"""
|
||||||
|
# 兼容不同调用方式
|
||||||
|
if device_id is None and 'id' in kwargs:
|
||||||
|
device_id = kwargs.pop('id')
|
||||||
|
if config is None and 'config' in kwargs:
|
||||||
|
config = kwargs.pop('config')
|
||||||
|
|
||||||
|
self.device_id = device_id or "unknown_device"
|
||||||
|
self.config = config or {}
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
|
# 从config读取参数
|
||||||
|
self.port = self.config.get('port') or kwargs.get('port', 'COM1')
|
||||||
|
self._max_value = self.config.get('max_value', 1000.0)
|
||||||
|
|
||||||
|
# 初始化日志
|
||||||
|
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||||
|
|
||||||
|
self.logger.info(f"设备 {self.device_id} 已创建")
|
||||||
|
|
||||||
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
|
"""
|
||||||
|
ROS节点注入 - 在ROS节点创建后调用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ros_node: ROS2设备节点实例
|
||||||
|
"""
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
async def initialize(self) -> bool:
|
||||||
|
"""
|
||||||
|
初始化设备 - 连接硬件、设置初始状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 初始化是否成功
|
||||||
|
"""
|
||||||
|
self.logger.info(f"初始化设备 {self.device_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 执行硬件初始化
|
||||||
|
# await self._connect_hardware()
|
||||||
|
|
||||||
|
# 设置初始状态
|
||||||
|
self.data.update({
|
||||||
|
"status": "待机",
|
||||||
|
"is_running": False,
|
||||||
|
"current_value": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.logger.info(f"设备 {self.device_id} 初始化完成")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"初始化失败: {e}")
|
||||||
|
self.data["status"] = f"错误: {e}"
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def cleanup(self) -> bool:
|
||||||
|
"""
|
||||||
|
清理设备 - 断开连接、释放资源
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 清理是否成功
|
||||||
|
"""
|
||||||
|
self.logger.info(f"清理设备 {self.device_id}")
|
||||||
|
|
||||||
|
self.data.update({
|
||||||
|
"status": "离线",
|
||||||
|
"is_running": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ==================== 设备动作 ====================
|
||||||
|
|
||||||
|
async def execute_action(
|
||||||
|
self,
|
||||||
|
param1: float,
|
||||||
|
param2: str = "",
|
||||||
|
**kwargs
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
执行设备动作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param1: 参数1
|
||||||
|
param2: 参数2(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 动作是否成功
|
||||||
|
"""
|
||||||
|
# 类型转换和验证
|
||||||
|
try:
|
||||||
|
param1 = float(param1)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
self.logger.error(f"参数类型错误: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 参数验证
|
||||||
|
if param1 > self._max_value:
|
||||||
|
self.logger.error(f"参数超出范围: {param1} > {self._max_value}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.info(f"执行动作: param1={param1}, param2={param2}")
|
||||||
|
|
||||||
|
# 更新状态
|
||||||
|
self.data.update({
|
||||||
|
"status": "运行中",
|
||||||
|
"is_running": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 执行动作(带进度反馈)
|
||||||
|
duration = 10.0 # 秒
|
||||||
|
start_time = time_module.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
elapsed = time_module.time() - start_time
|
||||||
|
remaining = max(0, duration - elapsed)
|
||||||
|
progress = min(100, (elapsed / duration) * 100)
|
||||||
|
|
||||||
|
self.data.update({
|
||||||
|
"status": f"运行中: {progress:.0f}%",
|
||||||
|
"remaining_time": remaining,
|
||||||
|
})
|
||||||
|
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
await self._ros_node.sleep(1.0)
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
self.data.update({
|
||||||
|
"status": "完成",
|
||||||
|
"is_running": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.logger.info("动作执行完成")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ==================== 状态属性 ====================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
"""设备状态 - 自动发布为ROS Topic"""
|
||||||
|
return self.data.get("status", "未知")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""是否正在运行"""
|
||||||
|
return self.data.get("is_running", False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_value(self) -> float:
|
||||||
|
"""当前值"""
|
||||||
|
return self.data.get("current_value", 0.0)
|
||||||
|
|
||||||
|
# ==================== 辅助方法 ====================
|
||||||
|
|
||||||
|
def get_device_info(self) -> Dict[str, Any]:
|
||||||
|
"""获取设备信息"""
|
||||||
|
return {
|
||||||
|
"device_id": self.device_id,
|
||||||
|
"status": self.status,
|
||||||
|
"is_running": self.is_running,
|
||||||
|
"current_value": self.current_value,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"MyDevice({self.device_id}: {self.status})"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
### 1. 参数处理
|
||||||
|
|
||||||
|
所有动作方法的参数都可能以字符串形式传入,必须进行类型转换:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def my_action(self, value: float, **kwargs) -> bool:
|
||||||
|
# 始终进行类型转换
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
self.logger.error(f"参数类型错误: {e}")
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. vessel 参数处理
|
||||||
|
|
||||||
|
vessel 参数可能是字符串ID或字典:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||||
|
if isinstance(vessel, dict):
|
||||||
|
return vessel.get("id", "")
|
||||||
|
return str(vessel) if vessel else ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 状态更新
|
||||||
|
|
||||||
|
使用 `self.data` 字典存储状态,属性读取状态:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 更新状态
|
||||||
|
self.data["status"] = "运行中"
|
||||||
|
self.data["current_speed"] = 300.0
|
||||||
|
|
||||||
|
# 读取状态(通过属性)
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return self.data.get("status", "待机")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 异步等待
|
||||||
|
|
||||||
|
使用 ROS 节点的 sleep 方法:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 正确
|
||||||
|
await self._ros_node.sleep(1.0)
|
||||||
|
|
||||||
|
# 避免(除非在纯 Python 测试环境)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 进度反馈
|
||||||
|
|
||||||
|
长时间运行的操作需要提供进度反馈:
|
||||||
|
|
||||||
|
```python
|
||||||
|
while remaining > 0:
|
||||||
|
progress = (elapsed / total_time) * 100
|
||||||
|
self.data["status"] = f"运行中: {progress:.0f}%"
|
||||||
|
self.data["remaining_time"] = remaining
|
||||||
|
|
||||||
|
await self._ros_node.sleep(1.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 虚拟设备
|
||||||
|
|
||||||
|
虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录:
|
||||||
|
|
||||||
|
- 类名以 `Virtual` 开头
|
||||||
|
- 文件名以 `virtual_` 开头
|
||||||
|
- 模拟真实设备的行为和时序
|
||||||
|
- 使用表情符号增强日志可读性(可选)
|
||||||
|
|
||||||
|
## 工作站设备
|
||||||
|
|
||||||
|
工作站是组合多个设备的复杂设备:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||||
|
|
||||||
|
class MyWorkstation(WorkstationBase):
|
||||||
|
"""组合工作站"""
|
||||||
|
|
||||||
|
async def execute_workflow(self, workflow: Dict[str, Any]) -> bool:
|
||||||
|
"""执行工作流"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设备注册
|
||||||
|
|
||||||
|
设备类开发完成后,需要在注册表中注册:
|
||||||
|
|
||||||
|
1. 创建/编辑 `unilabos/registry/devices/my_category.yaml`
|
||||||
|
2. 添加设备配置(参考 `virtual_device.yaml`)
|
||||||
|
3. 运行 `--complete_registry` 自动生成 schema
|
||||||
240
.cursor/rules/protocol-development.mdc
Normal file
240
.cursor/rules/protocol-development.mdc
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
---
|
||||||
|
description: 协议编译器开发规范
|
||||||
|
globs: ["unilabos/compile/**/*.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 协议编译器开发规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
协议编译器负责将高级实验操作(如 Stir、Add、Filter)编译为设备可执行的动作序列。
|
||||||
|
|
||||||
|
## 文件命名
|
||||||
|
|
||||||
|
- 位置: `unilabos/compile/`
|
||||||
|
- 命名: `{operation}_protocol.py`
|
||||||
|
- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py`
|
||||||
|
|
||||||
|
## 协议函数模板
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import List, Dict, Any, Union
|
||||||
|
import networkx as nx
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .utils.unit_parser import parse_time_input
|
||||||
|
from .utils.vessel_parser import extract_vessel_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_{operation}_protocol(
|
||||||
|
G: nx.DiGraph,
|
||||||
|
vessel: Union[str, dict],
|
||||||
|
param1: Union[str, float] = "0",
|
||||||
|
param2: float = 0.0,
|
||||||
|
**kwargs
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
生成{操作}协议序列
|
||||||
|
|
||||||
|
Args:
|
||||||
|
G: 物理拓扑图 (NetworkX DiGraph)
|
||||||
|
vessel: 容器ID或Resource字典
|
||||||
|
param1: 参数1(支持字符串单位,如 "5 min")
|
||||||
|
param2: 参数2
|
||||||
|
**kwargs: 其他参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Dict]: 动作序列
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 参数无效时
|
||||||
|
"""
|
||||||
|
# 1. 提取 vessel_id
|
||||||
|
vessel_id = extract_vessel_id(vessel)
|
||||||
|
|
||||||
|
# 2. 验证参数
|
||||||
|
if not vessel_id:
|
||||||
|
raise ValueError("vessel 参数不能为空")
|
||||||
|
|
||||||
|
if vessel_id not in G.nodes():
|
||||||
|
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||||
|
|
||||||
|
# 3. 解析参数(支持单位)
|
||||||
|
parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0
|
||||||
|
|
||||||
|
# 4. 查找设备
|
||||||
|
device_id = find_connected_device(G, vessel_id, device_type="my_device")
|
||||||
|
|
||||||
|
# 5. 生成动作序列
|
||||||
|
action_sequence = []
|
||||||
|
|
||||||
|
action = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"action_name": "my_action",
|
||||||
|
"action_kwargs": {
|
||||||
|
"vessel": {"id": vessel_id}, # 始终使用字典格式
|
||||||
|
"param1": float(parsed_param1),
|
||||||
|
"param2": float(param2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action_sequence.append(action)
|
||||||
|
|
||||||
|
logger.info(f"生成协议: {len(action_sequence)} 个动作")
|
||||||
|
return action_sequence
|
||||||
|
|
||||||
|
|
||||||
|
def find_connected_device(
|
||||||
|
G: nx.DiGraph,
|
||||||
|
vessel_id: str,
|
||||||
|
device_type: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
查找与容器相连的设备
|
||||||
|
|
||||||
|
Args:
|
||||||
|
G: 拓扑图
|
||||||
|
vessel_id: 容器ID
|
||||||
|
device_type: 设备类型关键字
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 设备ID
|
||||||
|
"""
|
||||||
|
# 查找所有匹配类型的设备
|
||||||
|
device_nodes = []
|
||||||
|
for node in G.nodes():
|
||||||
|
node_class = G.nodes[node].get('class', '') or ''
|
||||||
|
if device_type.lower() in node_class.lower():
|
||||||
|
device_nodes.append(node)
|
||||||
|
|
||||||
|
# 检查连接
|
||||||
|
if vessel_id and device_nodes:
|
||||||
|
for device in device_nodes:
|
||||||
|
if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device):
|
||||||
|
return device
|
||||||
|
|
||||||
|
# 返回第一个可用设备
|
||||||
|
if device_nodes:
|
||||||
|
return device_nodes[0]
|
||||||
|
|
||||||
|
# 默认设备
|
||||||
|
return f"{device_type}_1"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
### 1. vessel 参数处理
|
||||||
|
|
||||||
|
vessel 参数可能是字符串或字典,需要统一处理:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||||
|
"""提取vessel_id"""
|
||||||
|
if isinstance(vessel, dict):
|
||||||
|
# 可能是 {"id": "xxx"} 或完整 Resource 对象
|
||||||
|
return vessel.get("id", list(vessel.values())[0].get("id", ""))
|
||||||
|
return str(vessel) if vessel else ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. action_kwargs 中的 vessel
|
||||||
|
|
||||||
|
始终使用 `{"id": vessel_id}` 格式传递 vessel:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 正确
|
||||||
|
"action_kwargs": {
|
||||||
|
"vessel": {"id": vessel_id}, # 字符串ID包装为字典
|
||||||
|
}
|
||||||
|
|
||||||
|
# 避免
|
||||||
|
"action_kwargs": {
|
||||||
|
"vessel": vessel_resource, # 不要传递完整 Resource 对象
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 单位解析
|
||||||
|
|
||||||
|
使用 `parse_time_input` 解析时间参数:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .utils.unit_parser import parse_time_input
|
||||||
|
|
||||||
|
# 支持格式: "5 min", "1 h", "300", "1.5 hours"
|
||||||
|
time_seconds = parse_time_input("5 min") # -> 300.0
|
||||||
|
time_seconds = parse_time_input(120) # -> 120.0
|
||||||
|
time_seconds = parse_time_input("1 h") # -> 3600.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 参数验证
|
||||||
|
|
||||||
|
所有参数必须进行验证和类型转换:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 验证范围
|
||||||
|
if speed < 10.0 or speed > 1500.0:
|
||||||
|
logger.warning(f"速度 {speed} 超出范围,修正为 300")
|
||||||
|
speed = 300.0
|
||||||
|
|
||||||
|
# 类型转换
|
||||||
|
param = float(param) if not isinstance(param, (int, float)) else param
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 日志记录
|
||||||
|
|
||||||
|
使用项目日志记录器:
|
||||||
|
|
||||||
|
```python
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def generate_protocol(...):
|
||||||
|
logger.info(f"开始生成协议...")
|
||||||
|
logger.debug(f"参数: vessel={vessel_id}, time={time}")
|
||||||
|
logger.warning(f"参数修正: {old_value} -> {new_value}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 便捷函数
|
||||||
|
|
||||||
|
为常用操作提供便捷函数:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
|
||||||
|
speed: float = 300.0) -> List[Dict[str, Any]]:
|
||||||
|
"""短时间搅拌(30秒)"""
|
||||||
|
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
|
||||||
|
|
||||||
|
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
|
||||||
|
time: str = "5 min") -> List[Dict[str, Any]]:
|
||||||
|
"""剧烈搅拌"""
|
||||||
|
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试函数
|
||||||
|
|
||||||
|
每个协议文件应包含测试函数:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_{operation}_protocol():
|
||||||
|
"""测试协议生成"""
|
||||||
|
# 测试参数处理
|
||||||
|
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
|
||||||
|
vessel_id = extract_vessel_id(vessel_dict)
|
||||||
|
assert vessel_id == "flask_1"
|
||||||
|
|
||||||
|
# 测试单位解析
|
||||||
|
time_s = parse_time_input("5 min")
|
||||||
|
assert time_s == 300.0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_{operation}_protocol()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 现有协议参考
|
||||||
|
|
||||||
|
- `stir_protocol.py` - 搅拌操作
|
||||||
|
- `add_protocol.py` - 添加物料
|
||||||
|
- `filter_protocol.py` - 过滤操作
|
||||||
|
- `heatchill_protocol.py` - 加热/冷却
|
||||||
|
- `separate_protocol.py` - 分离操作
|
||||||
|
- `evaporate_protocol.py` - 蒸发操作
|
||||||
319
.cursor/rules/registry-config.mdc
Normal file
319
.cursor/rules/registry-config.mdc
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
---
|
||||||
|
description: 注册表配置规范 (YAML)
|
||||||
|
globs: ["unilabos/registry/**/*.yaml"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 注册表配置规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
unilabos/registry/
|
||||||
|
├── devices/ # 设备类型注册
|
||||||
|
│ ├── virtual_device.yaml
|
||||||
|
│ ├── liquid_handler.yaml
|
||||||
|
│ └── ...
|
||||||
|
├── device_comms/ # 通信设备配置
|
||||||
|
│ ├── communication_devices.yaml
|
||||||
|
│ └── modbus_ioboard.yaml
|
||||||
|
└── resources/ # 资源类型注册
|
||||||
|
├── bioyond/
|
||||||
|
├── organic/
|
||||||
|
├── opentrons/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设备注册表格式
|
||||||
|
|
||||||
|
### 基本结构
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
device_type_id:
|
||||||
|
# 基本信息
|
||||||
|
description: "设备描述"
|
||||||
|
version: "1.0.0"
|
||||||
|
category:
|
||||||
|
- category_name
|
||||||
|
icon: "icon_device.webp"
|
||||||
|
|
||||||
|
# 类配置
|
||||||
|
class:
|
||||||
|
module: "unilabos.devices.my_module:MyClass"
|
||||||
|
type: python
|
||||||
|
|
||||||
|
# 状态类型(属性 -> ROS消息类型)
|
||||||
|
status_types:
|
||||||
|
status: String
|
||||||
|
temperature: Float64
|
||||||
|
is_running: Bool
|
||||||
|
|
||||||
|
# 动作映射
|
||||||
|
action_value_mappings:
|
||||||
|
action_name:
|
||||||
|
type: UniLabJsonCommand # 或 UniLabJsonCommandAsync
|
||||||
|
goal: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
schema: {...}
|
||||||
|
handles: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### action_value_mappings 详细格式
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action_value_mappings:
|
||||||
|
# 同步动作
|
||||||
|
my_sync_action:
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
goal:
|
||||||
|
param1: param1
|
||||||
|
param2: param2
|
||||||
|
feedback: {}
|
||||||
|
result:
|
||||||
|
success: success
|
||||||
|
message: message
|
||||||
|
goal_default:
|
||||||
|
param1: 0.0
|
||||||
|
param2: ""
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys:
|
||||||
|
device_param: unilabos_devices # 设备选择器
|
||||||
|
resource_param: unilabos_resources # 资源选择器
|
||||||
|
schema:
|
||||||
|
title: "动作名称参数"
|
||||||
|
description: "动作描述"
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
param1:
|
||||||
|
type: number
|
||||||
|
param2:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- param1
|
||||||
|
feedback: {}
|
||||||
|
result:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
|
||||||
|
# 异步动作
|
||||||
|
my_async_action:
|
||||||
|
type: UniLabJsonCommandAsync
|
||||||
|
goal: {}
|
||||||
|
feedback:
|
||||||
|
progress: progress
|
||||||
|
current_status: status
|
||||||
|
result:
|
||||||
|
success: success
|
||||||
|
schema: {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动生成的动作
|
||||||
|
|
||||||
|
以 `auto-` 开头的动作由系统自动生成:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action_value_mappings:
|
||||||
|
auto-initialize:
|
||||||
|
type: UniLabJsonCommandAsync
|
||||||
|
goal: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
schema: {...}
|
||||||
|
|
||||||
|
auto-cleanup:
|
||||||
|
type: UniLabJsonCommandAsync
|
||||||
|
goal: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
schema: {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### handles 配置
|
||||||
|
|
||||||
|
用于工作流编辑器中的数据流连接:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
handles:
|
||||||
|
input:
|
||||||
|
- handler_key: "input_resource"
|
||||||
|
data_type: "resource"
|
||||||
|
label: "输入资源"
|
||||||
|
data_source: "handle"
|
||||||
|
data_key: "resources"
|
||||||
|
output:
|
||||||
|
- handler_key: "output_labware"
|
||||||
|
data_type: "resource"
|
||||||
|
label: "输出器皿"
|
||||||
|
data_source: "executor"
|
||||||
|
data_key: "created_resource.@flatten"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 资源注册表格式
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resource_type_id:
|
||||||
|
description: "资源描述"
|
||||||
|
version: "1.0.0"
|
||||||
|
category:
|
||||||
|
- category_name
|
||||||
|
icon: ""
|
||||||
|
handles: []
|
||||||
|
init_param_schema: {}
|
||||||
|
|
||||||
|
class:
|
||||||
|
module: "unilabos.resources.my_module:MyResource"
|
||||||
|
type: pylabrobot # 或 python
|
||||||
|
```
|
||||||
|
|
||||||
|
### PyLabRobot 资源示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
BIOYOND_Electrolyte_6VialCarrier:
|
||||||
|
category:
|
||||||
|
- bottle_carriers
|
||||||
|
- bioyond
|
||||||
|
class:
|
||||||
|
module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier"
|
||||||
|
type: pylabrobot
|
||||||
|
version: "1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 状态类型映射
|
||||||
|
|
||||||
|
Python 类型到 ROS 消息类型的映射:
|
||||||
|
|
||||||
|
| Python 类型 | ROS 消息类型 |
|
||||||
|
|------------|-------------|
|
||||||
|
| `str` | `String` |
|
||||||
|
| `bool` | `Bool` |
|
||||||
|
| `int` | `Int64` |
|
||||||
|
| `float` | `Float64` |
|
||||||
|
| `list` | `String` (序列化) |
|
||||||
|
| `dict` | `String` (序列化) |
|
||||||
|
|
||||||
|
## 自动完善注册表
|
||||||
|
|
||||||
|
使用 `--complete_registry` 参数自动生成 schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unilabos.app.main --complete_registry
|
||||||
|
```
|
||||||
|
|
||||||
|
这会:
|
||||||
|
1. 扫描设备类的方法签名
|
||||||
|
2. 自动生成 `auto-` 前缀的动作
|
||||||
|
3. 生成 JSON Schema
|
||||||
|
4. 更新 YAML 文件
|
||||||
|
|
||||||
|
## 验证规则
|
||||||
|
|
||||||
|
1. **device_type_id** 必须唯一
|
||||||
|
2. **module** 路径必须正确可导入
|
||||||
|
3. **status_types** 的类型必须是有效的 ROS 消息类型
|
||||||
|
4. **schema** 必须是有效的 JSON Schema
|
||||||
|
|
||||||
|
## 示例:完整设备配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
virtual_stirrer:
|
||||||
|
category:
|
||||||
|
- virtual_device
|
||||||
|
description: "虚拟搅拌器设备"
|
||||||
|
version: "1.0.0"
|
||||||
|
icon: "icon_stirrer.webp"
|
||||||
|
handles: []
|
||||||
|
init_param_schema: {}
|
||||||
|
|
||||||
|
class:
|
||||||
|
module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer"
|
||||||
|
type: python
|
||||||
|
|
||||||
|
status_types:
|
||||||
|
status: String
|
||||||
|
operation_mode: String
|
||||||
|
current_speed: Float64
|
||||||
|
is_stirring: Bool
|
||||||
|
remaining_time: Float64
|
||||||
|
|
||||||
|
action_value_mappings:
|
||||||
|
auto-initialize:
|
||||||
|
type: UniLabJsonCommandAsync
|
||||||
|
goal: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
title: "initialize参数"
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
type: object
|
||||||
|
properties: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
|
||||||
|
stir:
|
||||||
|
type: UniLabJsonCommandAsync
|
||||||
|
goal:
|
||||||
|
stir_time: stir_time
|
||||||
|
stir_speed: stir_speed
|
||||||
|
settling_time: settling_time
|
||||||
|
feedback:
|
||||||
|
current_speed: current_speed
|
||||||
|
remaining_time: remaining_time
|
||||||
|
result:
|
||||||
|
success: success
|
||||||
|
goal_default:
|
||||||
|
stir_time: 60.0
|
||||||
|
stir_speed: 300.0
|
||||||
|
settling_time: 30.0
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
title: "stir参数"
|
||||||
|
description: "搅拌操作"
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
stir_time:
|
||||||
|
type: number
|
||||||
|
description: "搅拌时间(秒)"
|
||||||
|
stir_speed:
|
||||||
|
type: number
|
||||||
|
description: "搅拌速度(RPM)"
|
||||||
|
settling_time:
|
||||||
|
type: number
|
||||||
|
description: "沉降时间(秒)"
|
||||||
|
required:
|
||||||
|
- stir_time
|
||||||
|
- stir_speed
|
||||||
|
feedback:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
current_speed:
|
||||||
|
type: number
|
||||||
|
remaining_time:
|
||||||
|
type: number
|
||||||
|
result:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
```
|
||||||
233
.cursor/rules/ros-integration.mdc
Normal file
233
.cursor/rules/ros-integration.mdc
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
---
|
||||||
|
description: ROS 2 集成开发规范
|
||||||
|
globs: ["unilabos/ros/**/*.py", "**/*_node.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# ROS 2 集成开发规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
### BaseROS2DeviceNode
|
||||||
|
|
||||||
|
设备节点基类,提供:
|
||||||
|
- ROS Topic 自动发布(状态属性)
|
||||||
|
- Action Server 自动创建(设备动作)
|
||||||
|
- 资源管理服务
|
||||||
|
- 异步任务调度
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
```
|
||||||
|
|
||||||
|
### 消息转换器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.ros.msgs.message_converter import (
|
||||||
|
convert_to_ros_msg,
|
||||||
|
convert_from_ros_msg_with_mapping,
|
||||||
|
msg_converter_manager,
|
||||||
|
ros_action_to_json_schema,
|
||||||
|
ros_message_to_json_schema,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设备与 ROS 集成
|
||||||
|
|
||||||
|
### post_init 方法
|
||||||
|
|
||||||
|
设备类必须实现 `post_init` 方法接收 ROS 节点:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyDevice:
|
||||||
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
|
"""ROS节点注入"""
|
||||||
|
self._ros_node = ros_node
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态属性发布
|
||||||
|
|
||||||
|
设备的 `@property` 属性会自动发布为 ROS Topic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyDevice:
|
||||||
|
@property
|
||||||
|
def temperature(self) -> float:
|
||||||
|
return self._temperature
|
||||||
|
|
||||||
|
# 自动发布到 /{namespace}/temperature Topic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Topic 配置装饰器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.decorator import topic_config
|
||||||
|
|
||||||
|
class MyDevice:
|
||||||
|
@property
|
||||||
|
@topic_config(period=1.0, print_publish=False, qos=10)
|
||||||
|
def fast_data(self) -> float:
|
||||||
|
"""高频数据 - 每秒发布一次"""
|
||||||
|
return self._fast_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
@topic_config(period=5.0)
|
||||||
|
def slow_data(self) -> str:
|
||||||
|
"""低频数据 - 每5秒发布一次"""
|
||||||
|
return self._slow_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 订阅装饰器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.decorator import subscribe
|
||||||
|
|
||||||
|
class MyDevice:
|
||||||
|
@subscribe(topic="/external/sensor_data", qos=10)
|
||||||
|
def on_sensor_data(self, msg):
|
||||||
|
"""订阅外部Topic"""
|
||||||
|
self._sensor_value = msg.data
|
||||||
|
```
|
||||||
|
|
||||||
|
## 异步操作
|
||||||
|
|
||||||
|
### 使用 ROS 节点睡眠
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 推荐:使用ROS节点的睡眠方法
|
||||||
|
await self._ros_node.sleep(1.0)
|
||||||
|
|
||||||
|
# 不推荐:直接使用asyncio(可能导致回调阻塞)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取事件循环
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.ros.x.rclpyx import get_event_loop
|
||||||
|
|
||||||
|
loop = get_event_loop()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 消息类型
|
||||||
|
|
||||||
|
### unilabos_msgs 包
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos_msgs.msg import Resource
|
||||||
|
from unilabos_msgs.srv import (
|
||||||
|
ResourceAdd,
|
||||||
|
ResourceDelete,
|
||||||
|
ResourceUpdate,
|
||||||
|
ResourceList,
|
||||||
|
SerialCommand,
|
||||||
|
)
|
||||||
|
from unilabos_msgs.action import SendCmd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource 消息结构
|
||||||
|
|
||||||
|
```python
|
||||||
|
Resource:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
type: str
|
||||||
|
parent: str
|
||||||
|
children: List[str]
|
||||||
|
config: str # JSON字符串
|
||||||
|
data: str # JSON字符串
|
||||||
|
sample_id: str
|
||||||
|
pose: Pose
|
||||||
|
```
|
||||||
|
|
||||||
|
## 日志适配器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.log import info, debug, warning, error, trace
|
||||||
|
|
||||||
|
class MyDevice:
|
||||||
|
def __init__(self):
|
||||||
|
# 创建设备专属日志器
|
||||||
|
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。
|
||||||
|
|
||||||
|
## Action Server
|
||||||
|
|
||||||
|
设备动作自动创建为 ROS Action Server:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 在注册表中配置
|
||||||
|
action_value_mappings:
|
||||||
|
my_action:
|
||||||
|
type: UniLabJsonCommandAsync # 异步Action
|
||||||
|
goal: {...}
|
||||||
|
feedback: {...}
|
||||||
|
result: {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action 类型
|
||||||
|
|
||||||
|
- **UniLabJsonCommand**: 同步动作
|
||||||
|
- **UniLabJsonCommandAsync**: 异步动作(支持feedback)
|
||||||
|
|
||||||
|
## 服务客户端
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rclpy.client import Client
|
||||||
|
|
||||||
|
# 调用其他节点的服务
|
||||||
|
response = await self._ros_node.call_service(
|
||||||
|
service_name="/other_node/service",
|
||||||
|
request=MyServiceRequest(...)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命名空间
|
||||||
|
|
||||||
|
设备节点使用命名空间隔离:
|
||||||
|
|
||||||
|
```
|
||||||
|
/{device_id}/ # 设备命名空间
|
||||||
|
/{device_id}/status # 状态Topic
|
||||||
|
/{device_id}/temperature # 温度Topic
|
||||||
|
/{device_id}/my_action # 动作Server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试
|
||||||
|
|
||||||
|
### 查看 Topic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic list
|
||||||
|
ros2 topic echo /{device_id}/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看 Action
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 action list
|
||||||
|
ros2 action info /{device_id}/my_action
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看 Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 service list
|
||||||
|
ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **状态属性命名**: 使用蛇形命名法(snake_case)
|
||||||
|
2. **Topic 频率**: 根据数据变化频率调整,避免过高频率
|
||||||
|
3. **Action 反馈**: 长时间操作提供进度反馈
|
||||||
|
4. **错误处理**: 使用 try-except 捕获并记录错误
|
||||||
|
5. **资源清理**: 在 cleanup 方法中正确清理资源
|
||||||
357
.cursor/rules/testing-patterns.mdc
Normal file
357
.cursor/rules/testing-patterns.mdc
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
---
|
||||||
|
description: 测试开发规范
|
||||||
|
globs: ["tests/**/*.py", "**/test_*.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 测试开发规范
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── __init__.py
|
||||||
|
├── devices/ # 设备测试
|
||||||
|
│ └── liquid_handling/
|
||||||
|
│ └── test_transfer_liquid.py
|
||||||
|
├── resources/ # 资源测试
|
||||||
|
│ ├── test_bottle_carrier.py
|
||||||
|
│ └── test_resourcetreeset.py
|
||||||
|
├── ros/ # ROS消息测试
|
||||||
|
│ └── msgs/
|
||||||
|
│ ├── test_basic.py
|
||||||
|
│ ├── test_conversion.py
|
||||||
|
│ └── test_mapping.py
|
||||||
|
└── workflow/ # 工作流测试
|
||||||
|
└── merge_workflow.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试框架
|
||||||
|
|
||||||
|
使用 pytest 作为测试框架:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行所有测试
|
||||||
|
pytest tests/
|
||||||
|
|
||||||
|
# 运行特定测试文件
|
||||||
|
pytest tests/resources/test_bottle_carrier.py
|
||||||
|
|
||||||
|
# 运行特定测试函数
|
||||||
|
pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier
|
||||||
|
|
||||||
|
# 显示详细输出
|
||||||
|
pytest -v tests/
|
||||||
|
|
||||||
|
# 显示打印输出
|
||||||
|
pytest -s tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试文件模板
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
# 导入被测试的模块
|
||||||
|
from unilabos.resources.bioyond.bottle_carriers import (
|
||||||
|
BIOYOND_Electrolyte_6VialCarrier,
|
||||||
|
)
|
||||||
|
from unilabos.resources.bioyond.bottles import (
|
||||||
|
BIOYOND_PolymerStation_Solid_Vial,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBottleCarrier:
|
||||||
|
"""BottleCarrier 测试类"""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""每个测试方法前执行"""
|
||||||
|
self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier")
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""每个测试方法后执行"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_carrier_creation(self):
|
||||||
|
"""测试载架创建"""
|
||||||
|
assert self.carrier.name == "test_carrier"
|
||||||
|
assert len(self.carrier.sites) == 6
|
||||||
|
|
||||||
|
def test_bottle_placement(self):
|
||||||
|
"""测试瓶子放置"""
|
||||||
|
bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle")
|
||||||
|
# 测试逻辑...
|
||||||
|
assert bottle.name == "test_bottle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_standalone_function():
|
||||||
|
"""独立测试函数"""
|
||||||
|
result = some_function()
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
# 参数化测试
|
||||||
|
@pytest.mark.parametrize("input,expected", [
|
||||||
|
("5 min", 300.0),
|
||||||
|
("1 h", 3600.0),
|
||||||
|
("120", 120.0),
|
||||||
|
(60, 60.0),
|
||||||
|
])
|
||||||
|
def test_time_parsing(input, expected):
|
||||||
|
"""测试时间解析"""
|
||||||
|
from unilabos.compile.utils.unit_parser import parse_time_input
|
||||||
|
assert parse_time_input(input) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# 异常测试
|
||||||
|
def test_invalid_input_raises_error():
|
||||||
|
"""测试无效输入抛出异常"""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
invalid_function("bad_input")
|
||||||
|
assert "invalid" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
# 跳过条件测试
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not os.environ.get("ROS_DISTRO"),
|
||||||
|
reason="需要ROS环境"
|
||||||
|
)
|
||||||
|
def test_ros_feature():
|
||||||
|
"""需要ROS环境的测试"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设备测试
|
||||||
|
|
||||||
|
### 虚拟设备测试
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import MagicMock, AsyncMock
|
||||||
|
|
||||||
|
from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer
|
||||||
|
|
||||||
|
|
||||||
|
class TestVirtualStirrer:
|
||||||
|
"""VirtualStirrer 测试"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stirrer(self):
|
||||||
|
"""创建测试用搅拌器"""
|
||||||
|
device = VirtualStirrer(
|
||||||
|
device_id="test_stirrer",
|
||||||
|
config={"max_speed": 1500.0, "min_speed": 50.0}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock ROS节点
|
||||||
|
mock_node = MagicMock()
|
||||||
|
mock_node.sleep = AsyncMock(return_value=None)
|
||||||
|
device.post_init(mock_node)
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize(self, stirrer):
|
||||||
|
"""测试初始化"""
|
||||||
|
result = await stirrer.initialize()
|
||||||
|
assert result is True
|
||||||
|
assert stirrer.status == "待机中"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stir_action(self, stirrer):
|
||||||
|
"""测试搅拌动作"""
|
||||||
|
await stirrer.initialize()
|
||||||
|
|
||||||
|
result = await stirrer.stir(
|
||||||
|
stir_time=5.0,
|
||||||
|
stir_speed=300.0,
|
||||||
|
settling_time=2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert stirrer.operation_mode == "Completed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stir_invalid_speed(self, stirrer):
|
||||||
|
"""测试无效速度"""
|
||||||
|
await stirrer.initialize()
|
||||||
|
|
||||||
|
# 速度超出范围
|
||||||
|
result = await stirrer.stir(
|
||||||
|
stir_time=5.0,
|
||||||
|
stir_speed=2000.0, # 超过max_speed
|
||||||
|
settling_time=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert "错误" in stirrer.status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 异步测试配置
|
||||||
|
|
||||||
|
```python
|
||||||
|
# conftest.py
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
"""创建事件循环"""
|
||||||
|
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 资源测试
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from unilabos.resources.resource_tracker import (
|
||||||
|
ResourceTreeSet,
|
||||||
|
ResourceTreeInstance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_tree_creation():
|
||||||
|
"""测试资源树创建"""
|
||||||
|
tree_set = ResourceTreeSet()
|
||||||
|
|
||||||
|
# 添加资源
|
||||||
|
resource = {"id": "res_1", "name": "Resource 1"}
|
||||||
|
tree_set.add_resource(resource)
|
||||||
|
|
||||||
|
# 验证
|
||||||
|
assert len(tree_set.all_nodes) == 1
|
||||||
|
assert tree_set.get_resource("res_1") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_tree_merge():
|
||||||
|
"""测试资源树合并"""
|
||||||
|
local_set = ResourceTreeSet()
|
||||||
|
remote_set = ResourceTreeSet()
|
||||||
|
|
||||||
|
# 设置数据...
|
||||||
|
|
||||||
|
local_set.merge_remote_resources(remote_set)
|
||||||
|
|
||||||
|
# 验证合并结果...
|
||||||
|
```
|
||||||
|
|
||||||
|
## ROS 消息测试
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from unilabos.ros.msgs.message_converter import (
|
||||||
|
convert_to_ros_msg,
|
||||||
|
convert_from_ros_msg_with_mapping,
|
||||||
|
msg_converter_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_conversion():
|
||||||
|
"""测试消息转换"""
|
||||||
|
# Python -> ROS
|
||||||
|
python_data = {"id": "test", "value": 42}
|
||||||
|
ros_msg = convert_to_ros_msg(python_data, MyMsgType)
|
||||||
|
|
||||||
|
assert ros_msg.id == "test"
|
||||||
|
assert ros_msg.value == 42
|
||||||
|
|
||||||
|
# ROS -> Python
|
||||||
|
result = convert_from_ros_msg_with_mapping(ros_msg, mapping)
|
||||||
|
assert result["id"] == "test"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 协议测试
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
import networkx as nx
|
||||||
|
from unilabos.compile.stir_protocol import (
|
||||||
|
generate_stir_protocol,
|
||||||
|
extract_vessel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def topology_graph():
|
||||||
|
"""创建测试拓扑图"""
|
||||||
|
G = nx.DiGraph()
|
||||||
|
G.add_node("flask_1", **{"class": "flask"})
|
||||||
|
G.add_node("stirrer_1", **{"class": "virtual_stirrer"})
|
||||||
|
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"
|
||||||
|
assert actions[0]["action_name"] == "stir"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_vessel_id():
|
||||||
|
"""测试vessel_id提取"""
|
||||||
|
# 字典格式
|
||||||
|
assert extract_vessel_id({"id": "flask_1"}) == "flask_1"
|
||||||
|
|
||||||
|
# 字符串格式
|
||||||
|
assert extract_vessel_id("flask_2") == "flask_2"
|
||||||
|
|
||||||
|
# 空值
|
||||||
|
assert extract_vessel_id("") == ""
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试标记
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 慢速测试
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_long_running():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 需要网络
|
||||||
|
@pytest.mark.network
|
||||||
|
def test_network_call():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 需要ROS
|
||||||
|
@pytest.mark.ros
|
||||||
|
def test_ros_feature():
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
运行特定标记的测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -m "not slow" # 排除慢速测试
|
||||||
|
pytest -m ros # 仅ROS测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 覆盖率
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成覆盖率报告
|
||||||
|
pytest --cov=unilabos tests/
|
||||||
|
|
||||||
|
# HTML报告
|
||||||
|
pytest --cov=unilabos --cov-report=html tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **测试命名**: `test_{功能}_{场景}_{预期结果}`
|
||||||
|
2. **独立性**: 每个测试独立运行,不依赖其他测试
|
||||||
|
3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务
|
||||||
|
4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码
|
||||||
|
5. **fixtures**: 使用 fixtures 共享测试设置
|
||||||
|
6. **断言清晰**: 每个断言只验证一件事
|
||||||
353
.cursor/rules/unilabos-project.mdc
Normal file
353
.cursor/rules/unilabos-project.mdc
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
---
|
||||||
|
description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则
|
||||||
|
globs: ["**/*.py", "**/*.yaml", "**/*.json"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Uni-Lab-OS 项目开发规范
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **Python 3.11** - 核心开发语言
|
||||||
|
- **ROS 2** - 设备通信中间件 (rclpy)
|
||||||
|
- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge)
|
||||||
|
- **FastAPI** - Web API 服务
|
||||||
|
- **WebSocket** - 实时通信
|
||||||
|
- **NetworkX** - 拓扑图管理
|
||||||
|
- **YAML** - 配置和注册表定义
|
||||||
|
- **PyLabRobot** - 实验室自动化库集成
|
||||||
|
- **pytest** - 测试框架
|
||||||
|
- **asyncio** - 异步编程
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
unilabos/
|
||||||
|
├── app/ # 应用入口、Web服务、后端
|
||||||
|
├── compile/ # 协议编译器 (stir, add, filter 等)
|
||||||
|
├── config/ # 配置管理
|
||||||
|
├── devices/ # 设备驱动 (真实/虚拟)
|
||||||
|
├── device_comms/ # 设备通信协议
|
||||||
|
├── device_mesh/ # 3D网格和可视化
|
||||||
|
├── registry/ # 设备和资源类型注册表 (YAML)
|
||||||
|
├── resources/ # 资源定义
|
||||||
|
├── ros/ # ROS 2 集成
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
└── workflow/ # 工作流管理
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
### Python 风格
|
||||||
|
|
||||||
|
1. **类型注解**:所有函数必须使用类型注解
|
||||||
|
```python
|
||||||
|
def transfer_liquid(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
volume: float,
|
||||||
|
**kwargs
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Docstring**:使用 Google 风格的文档字符串
|
||||||
|
```python
|
||||||
|
def initialize(self) -> bool:
|
||||||
|
"""
|
||||||
|
初始化设备
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 初始化是否成功
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **导入顺序**:
|
||||||
|
- 标准库
|
||||||
|
- 第三方库
|
||||||
|
- ROS 相关 (rclpy, unilabos_msgs)
|
||||||
|
- 项目内部模块
|
||||||
|
|
||||||
|
### 异步编程
|
||||||
|
|
||||||
|
1. 设备操作方法使用 `async def`
|
||||||
|
2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()`
|
||||||
|
3. 长时间运行操作需提供进度反馈
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool:
|
||||||
|
"""执行搅拌操作"""
|
||||||
|
start_time = time_module.time()
|
||||||
|
while True:
|
||||||
|
elapsed = time_module.time() - start_time
|
||||||
|
remaining = max(0, stir_time - elapsed)
|
||||||
|
|
||||||
|
self.data.update({
|
||||||
|
"remaining_time": remaining,
|
||||||
|
"status": f"搅拌中: {stir_speed} RPM"
|
||||||
|
})
|
||||||
|
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
await self._ros_node.sleep(1.0)
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志规范
|
||||||
|
|
||||||
|
使用项目自定义日志系统:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.log import logger, info, debug, warning, error, trace
|
||||||
|
|
||||||
|
# 在设备类中使用
|
||||||
|
self.logger = logging.getLogger(f"DeviceName.{self.device_id}")
|
||||||
|
self.logger.info("设备初始化完成")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设备驱动开发
|
||||||
|
|
||||||
|
### 设备类结构
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
|
class MyDevice:
|
||||||
|
"""设备驱动类"""
|
||||||
|
|
||||||
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
|
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
|
||||||
|
self.device_id = device_id or "unknown_device"
|
||||||
|
self.config = config or {}
|
||||||
|
self.data = {} # 设备状态数据
|
||||||
|
|
||||||
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
|
"""ROS节点注入"""
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
async def initialize(self) -> bool:
|
||||||
|
"""初始化设备"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def cleanup(self) -> bool:
|
||||||
|
"""清理设备"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 状态属性 - 自动发布为 ROS Topic
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return self.data.get("status", "待机")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态属性装饰器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.decorator import topic_config
|
||||||
|
|
||||||
|
class MyDevice:
|
||||||
|
@property
|
||||||
|
@topic_config(period=1.0, qos=10) # 每秒发布一次
|
||||||
|
def temperature(self) -> float:
|
||||||
|
return self._temperature
|
||||||
|
```
|
||||||
|
|
||||||
|
### 虚拟设备
|
||||||
|
|
||||||
|
虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py`
|
||||||
|
|
||||||
|
## 注册表配置
|
||||||
|
|
||||||
|
### 设备注册表 (YAML)
|
||||||
|
|
||||||
|
位置: `unilabos/registry/devices/*.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
my_device_type:
|
||||||
|
category:
|
||||||
|
- my_category
|
||||||
|
description: "设备描述"
|
||||||
|
version: "1.0.0"
|
||||||
|
class:
|
||||||
|
module: "unilabos.devices.my_device:MyDevice"
|
||||||
|
type: python
|
||||||
|
status_types:
|
||||||
|
status: String
|
||||||
|
temperature: Float64
|
||||||
|
action_value_mappings:
|
||||||
|
auto-initialize:
|
||||||
|
type: UniLabJsonCommandAsync
|
||||||
|
goal: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
schema: {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 资源注册表 (YAML)
|
||||||
|
|
||||||
|
位置: `unilabos/registry/resources/**/*.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
my_container:
|
||||||
|
category:
|
||||||
|
- container
|
||||||
|
class:
|
||||||
|
module: "unilabos.resources.my_resource:MyContainer"
|
||||||
|
type: pylabrobot
|
||||||
|
version: "1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 协议编译器
|
||||||
|
|
||||||
|
位置: `unilabos/compile/*_protocol.py`
|
||||||
|
|
||||||
|
### 协议生成函数模板
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import List, Dict, Any, Union
|
||||||
|
import networkx as nx
|
||||||
|
|
||||||
|
def generate_my_protocol(
|
||||||
|
G: nx.DiGraph,
|
||||||
|
vessel: Union[str, dict],
|
||||||
|
param1: float = 0.0,
|
||||||
|
**kwargs
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
生成操作协议序列
|
||||||
|
|
||||||
|
Args:
|
||||||
|
G: 物理拓扑图
|
||||||
|
vessel: 容器ID或字典
|
||||||
|
param1: 参数1
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Dict]: 动作序列
|
||||||
|
"""
|
||||||
|
# 提取vessel_id
|
||||||
|
vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "")
|
||||||
|
|
||||||
|
# 查找设备
|
||||||
|
device_id = find_connected_device(G, vessel_id)
|
||||||
|
|
||||||
|
# 生成动作
|
||||||
|
action_sequence = [{
|
||||||
|
"device_id": device_id,
|
||||||
|
"action_name": "my_action",
|
||||||
|
"action_kwargs": {
|
||||||
|
"vessel": {"id": vessel_id},
|
||||||
|
"param1": float(param1)
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
|
||||||
|
return action_sequence
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试规范
|
||||||
|
|
||||||
|
### 测试文件位置
|
||||||
|
|
||||||
|
- 单元测试: `tests/` 目录
|
||||||
|
- 设备测试: `tests/devices/`
|
||||||
|
- 资源测试: `tests/resources/`
|
||||||
|
- ROS消息测试: `tests/ros/msgs/`
|
||||||
|
|
||||||
|
### 测试命名
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/devices/my_device/test_my_device.py
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
def test_device_initialization():
|
||||||
|
"""测试设备初始化"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_device_action():
|
||||||
|
"""测试设备动作"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.exception import UniLabException
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await device.execute_action()
|
||||||
|
except ValueError as e:
|
||||||
|
self.logger.error(f"参数错误: {e}")
|
||||||
|
self.data["status"] = "错误: 参数无效"
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"执行失败: {e}")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置管理
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.config.config import BasicConfig, HTTPConfig
|
||||||
|
|
||||||
|
# 读取配置
|
||||||
|
port = BasicConfig.port
|
||||||
|
is_host = BasicConfig.is_host_mode
|
||||||
|
|
||||||
|
# 配置文件: local_config.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用工具
|
||||||
|
|
||||||
|
### 单例模式
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.decorator import singleton
|
||||||
|
|
||||||
|
@singleton
|
||||||
|
class MyManager:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 类型检查
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.type_check import NoAliasDumper
|
||||||
|
|
||||||
|
yaml.dump(data, f, Dumper=NoAliasDumper)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 导入管理
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.utils.import_manager import get_class
|
||||||
|
|
||||||
|
device_class = get_class("unilabos.devices.my_device:MyDevice")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 提交规范
|
||||||
|
|
||||||
|
提交信息格式:
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
```
|
||||||
|
|
||||||
|
类型:
|
||||||
|
- `feat`: 新功能
|
||||||
|
- `fix`: 修复bug
|
||||||
|
- `docs`: 文档更新
|
||||||
|
- `refactor`: 重构
|
||||||
|
- `test`: 测试相关
|
||||||
|
- `chore`: 构建/工具相关
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```
|
||||||
|
feat(devices): 添加虚拟搅拌器设备
|
||||||
|
|
||||||
|
- 实现VirtualStirrer类
|
||||||
|
- 支持定时搅拌和持续搅拌模式
|
||||||
|
- 添加速度验证逻辑
|
||||||
|
```
|
||||||
@@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{
|
|||||||
|
|
||||||
### 2. --addr → BASE URL
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
| `--addr` 值 | BASE |
|
||||||
|-------------|------|
|
| ------------ | ----------------------------------- |
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
确认后设置:
|
确认后设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE="<根据 addr 确定的 URL>"
|
BASE="<根据 addr 确定的 URL>"
|
||||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
||||||
@@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|||||||
返回:
|
返回:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||||
```
|
```
|
||||||
|
|
||||||
记住 `data.uuid` 为 `lab_uuid`。
|
记住 `data.uuid` 为 `lab_uuid`。
|
||||||
@@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
返回成功时包含试剂 UUID:
|
返回成功时包含试剂 UUID:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
{"code": 0, "data": {"uuid": "xxx", ...}}
|
||||||
```
|
```
|
||||||
@@ -98,28 +100,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|||||||
|
|
||||||
## 试剂字段说明
|
## 试剂字段说明
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||||
|------|------|------|------|------|
|
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
|
||||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||||
|
|
||||||
### unit 单位值
|
### unit 单位值
|
||||||
|
|
||||||
| 值 | 单位 |
|
| 值 | 单位 |
|
||||||
|------|------|
|
| ------ | ---- |
|
||||||
| `"mL"` | 毫升 |
|
| `"mL"` | 毫升 |
|
||||||
| `"L"` | 升 |
|
| `"L"` | 升 |
|
||||||
| `"g"` | 克 |
|
| `"g"` | 克 |
|
||||||
| `"kg"` | 千克 |
|
| `"kg"` | 千克 |
|
||||||
| `"瓶"` | 瓶 |
|
| `"瓶"` | 瓶 |
|
||||||
|
|
||||||
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
||||||
|
|
||||||
@@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
|
{
|
||||||
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
|
"cas": "7732-18-3",
|
||||||
|
"name": "水",
|
||||||
|
"molecular_formula": "H2O",
|
||||||
|
"smiles": "O",
|
||||||
|
"stock_in_quantity": 10,
|
||||||
|
"unit": "mL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cas": "64-17-5",
|
||||||
|
"name": "乙醇",
|
||||||
|
"molecular_formula": "C2H6O",
|
||||||
|
"smiles": "CCO",
|
||||||
|
"stock_in_quantity": 5,
|
||||||
|
"unit": "L"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -160,9 +176,20 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
|
|||||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 日期格式规则(重要)
|
||||||
|
|
||||||
|
所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。
|
||||||
|
|
||||||
|
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
|
||||||
|
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
|
||||||
|
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
|
||||||
|
|
||||||
|
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。
|
||||||
|
|
||||||
### 执行与汇报
|
### 执行与汇报
|
||||||
|
|
||||||
每次 API 调用后:
|
每次 API 调用后:
|
||||||
|
|
||||||
1. 检查返回 `code`(0 = 成功)
|
1. 检查返回 `code`(0 = 成功)
|
||||||
2. 记录成功/失败数量
|
2. 记录成功/失败数量
|
||||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
||||||
@@ -172,28 +199,29 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
|
|||||||
|
|
||||||
## 常见试剂速查表
|
## 常见试剂速查表
|
||||||
|
|
||||||
| 名称 | CAS | 分子式 | SMILES |
|
| 名称 | CAS | 分子式 | SMILES |
|
||||||
|------|-----|--------|--------|
|
| --------------------- | --------- | ---------- | ------------------------------------ |
|
||||||
| 水 | 7732-18-3 | H2O | O |
|
| 水 | 7732-18-3 | H2O | O |
|
||||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
|
||||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||||
|
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||||
|
|
||||||
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: batch-submit-experiment
|
name: batch-submit-experiment
|
||||||
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
||||||
---
|
---
|
||||||
|
|
||||||
# 批量提交实验指南
|
# Uni-Lab 批量提交实验指南
|
||||||
|
|
||||||
通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||||
|
|
||||||
|
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
@@ -18,25 +20,28 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w
|
|||||||
生成 AUTH token(任选一种方式):
|
生成 AUTH token(任选一种方式):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 方式一:Python 一行生成
|
# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic")
|
||||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||||
|
|
||||||
# 方式二:手动计算
|
# 方式二:手动计算
|
||||||
# base64(ak:sk) → Authorization: Lab <token>
|
# base64(ak:sk) → Authorization: Lab <token>
|
||||||
|
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
| `--addr` 值 | BASE |
|
||||||
|-------------|------|
|
| ------------ | ----------------------------------- |
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
确认后设置:
|
确认后设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE="<根据 addr 确定的 URL>"
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,18 +49,19 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
|||||||
|
|
||||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
||||||
|
|
||||||
按优先级搜索:
|
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
|
||||||
|
|
||||||
```
|
```
|
||||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
Glob: **/req_device_registry_upload.json
|
||||||
<workspace 根目录>/req_device_registry_upload.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
|
常见位置(仅供参考,以 Glob 实际结果为准):
|
||||||
|
- `<workspace>/unilabos_data/req_device_registry_upload.json`
|
||||||
|
- `<workspace>/req_device_registry_upload.json`
|
||||||
|
|
||||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||||
|
|
||||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||||
|
|
||||||
### 4. workflow_uuid(目标工作流)
|
### 4. workflow_uuid(目标工作流)
|
||||||
|
|
||||||
@@ -93,7 +99,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|||||||
返回:
|
返回:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||||
```
|
```
|
||||||
|
|
||||||
记住 `data.uuid` 为 `lab_uuid`。
|
记住 `data.uuid` 为 `lab_uuid`。
|
||||||
@@ -104,9 +110,33 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|||||||
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||||
```
|
```
|
||||||
|
|
||||||
返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。
|
返回:
|
||||||
|
|
||||||
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"uuid": "1b3f249a-...",
|
||||||
|
"name": "bt",
|
||||||
|
"description": null,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-04-09T14:31:28+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "b6366243-...",
|
||||||
|
"name": "default",
|
||||||
|
"description": "默认项目",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-03-26T11:13:36+08:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
展示 `data.items[]` 中每个项目的 `name` 和 `uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
|
||||||
|
|
||||||
### 3. 列出可用 workflow
|
### 3. 列出可用 workflow
|
||||||
|
|
||||||
@@ -123,6 +153,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
|
|||||||
```
|
```
|
||||||
|
|
||||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||||
|
|
||||||
- 每个 action 节点的 `node_uuid`
|
- 每个 action 节点的 `node_uuid`
|
||||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||||
- 每个节点的动作名(`node_template_name`)
|
- 每个节点的动作名(`node_template_name`)
|
||||||
@@ -142,30 +173,30 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"lab_uuid": "<lab_uuid>",
|
"lab_uuid": "<lab_uuid>",
|
||||||
"project_uuid": "<project_uuid>",
|
"project_uuid": "<project_uuid>",
|
||||||
"workflow_uuid": "<workflow_uuid>",
|
"workflow_uuid": "<workflow_uuid>",
|
||||||
"name": "<实验名称>",
|
"name": "<实验名称>",
|
||||||
"node_params": [
|
"node_params": [
|
||||||
|
{
|
||||||
|
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||||
|
"datas": [
|
||||||
{
|
{
|
||||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
"node_uuid": "<workflow中的节点UUID>",
|
||||||
"datas": [
|
"param": {},
|
||||||
{
|
"sample_params": [
|
||||||
"node_uuid": "<workflow中的节点UUID>",
|
{
|
||||||
"param": {},
|
"container_uuid": "<容器UUID>",
|
||||||
"sample_params": [
|
"sample_value": {
|
||||||
{
|
"liquid_names": "<液体名称>",
|
||||||
"container_uuid": "<容器UUID>",
|
"volumes": 1000
|
||||||
"sample_value": {
|
}
|
||||||
"liquid_names": "<液体名称>",
|
}
|
||||||
"volumes": 1000
|
]
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -194,25 +225,25 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
|||||||
|
|
||||||
### 每轮的字段
|
### 每轮的字段
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| -------------- | ------------- | ----------------------------------------- |
|
||||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||||
|
|
||||||
### datas 中每个节点
|
### datas 中每个节点
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| --------------- | ------ | -------------------------------------------- |
|
||||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
||||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||||
|
|
||||||
### sample_params 中每条
|
### sample_params 中每条
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| ---------------- | ------ | ---------------------------------------------------- |
|
||||||
| `container_uuid` | string | 容器 UUID |
|
| `container_uuid` | string | 容器 UUID |
|
||||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -233,6 +264,7 @@ python scripts/gen_notebook_params.py \
|
|||||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
||||||
|
|
||||||
脚本会:
|
脚本会:
|
||||||
|
|
||||||
1. 调用 workflow detail API 获取所有 action 节点
|
1. 调用 workflow detail API 获取所有 action 节点
|
||||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
2. 读取本地注册表,为每个节点查找对应的 action schema
|
||||||
3. 生成 `notebook_template.json`,包含:
|
3. 生成 `notebook_template.json`,包含:
|
||||||
@@ -270,8 +302,11 @@ python scripts/gen_notebook_params.py \
|
|||||||
"properties": {
|
"properties": {
|
||||||
"goal": {
|
"goal": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"asp_vols": {"type": "array", "items": {"type": "number"}},
|
"asp_vols": {
|
||||||
"sources": {"type": "array"}
|
"type": "array",
|
||||||
|
"items": { "type": "number" }
|
||||||
|
},
|
||||||
|
"sources": { "type": "array" }
|
||||||
},
|
},
|
||||||
"required": ["asp_vols", "sources"]
|
"required": ["asp_vols", "sources"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
选项:
|
选项:
|
||||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
||||||
--base <url> API 基础 URL(如 https://uni-lab.test.bohrium.com)
|
--base <url> API 基础 URL(如 https://leap-lab.test.bohrium.com)
|
||||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
||||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
--registry <path> 本地注册表文件路径(默认自动搜索)
|
||||||
--rounds <n> 实验轮次数(默认 1)
|
--rounds <n> 实验轮次数(默认 1)
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
示例:
|
示例:
|
||||||
python gen_notebook_params.py \\
|
python gen_notebook_params.py \\
|
||||||
--auth YTFmZDlkNGUtxxxx \\
|
--auth YTFmZDlkNGUtxxxx \\
|
||||||
--base https://uni-lab.test.bohrium.com \\
|
--base https://leap-lab.test.bohrium.com \\
|
||||||
--workflow-uuid abc-123-def \\
|
--workflow-uuid abc-123-def \\
|
||||||
--rounds 2
|
--rounds 2
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
|
|||||||
|
|
||||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||||
|
|
||||||
| `--addr` 值 | BASE URL |
|
| `--addr` 值 | BASE URL |
|
||||||
|-------------|----------|
|
| -------------- | ----------------------------------- |
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
| 其他自定义 URL | 直接使用该 URL |
|
| 其他自定义 URL | 直接使用该 URL |
|
||||||
|
|
||||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||||
|
|
||||||
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
|
|||||||
|
|
||||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||||
|
|
||||||
| 条件 | working_dir 取值 |
|
| 条件 | working_dir 取值 |
|
||||||
|------|------------------|
|
| -------------------- | -------------------------------------------------------- |
|
||||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||||
|
|
||||||
**按优先级搜索文件**:
|
**按优先级搜索文件**:
|
||||||
|
|
||||||
@@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config <config.py>
|
|||||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 完整示例
|
|
||||||
|
|
||||||
用户提供:
|
|
||||||
|
|
||||||
```
|
|
||||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
|
||||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
|
||||||
--addr test
|
|
||||||
--port 8003
|
|
||||||
--disable_browser
|
|
||||||
```
|
|
||||||
|
|
||||||
从中提取:
|
|
||||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
|
||||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
|
||||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
|
||||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
|
||||||
|
|
||||||
**四项全部就绪后才进入 Step 1。**
|
**四项全部就绪后才进入 Step 1。**
|
||||||
|
|
||||||
### Step 1 — 列出可用设备
|
### Step 1 — 列出可用设备
|
||||||
@@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||||
|
|
||||||
每个 action 生成一个 JSON 文件,包含:
|
每个 action 生成一个 JSON 文件,包含:
|
||||||
|
|
||||||
- `type` — 作为 API 调用的 `action_type`
|
- `type` — 作为 API 调用的 `action_type`
|
||||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||||
@@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
|
|
||||||
### Step 3 — 写 action-index.md
|
### Step 3 — 写 action-index.md
|
||||||
|
|
||||||
按模板为每个 action 写条目:
|
按模板为每个 action 写条目(**必须包含 `action_type`**):
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
### `<action_name>`
|
### `<action_name>`
|
||||||
|
|
||||||
<用途描述(一句话)>
|
<用途描述(一句话)>
|
||||||
|
|
||||||
|
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
|
||||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||||
- **可选参数**: `param3`, `param4`
|
- **可选参数**: `param3`, `param4`
|
||||||
@@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
```
|
```
|
||||||
|
|
||||||
描述规则:
|
描述规则:
|
||||||
|
|
||||||
|
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
|
||||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||||
- 从 `schema.required` 区分核心/可选参数
|
- 从 `schema.required` 区分核心/可选参数
|
||||||
- 按功能分类(移液、枪头、外设等)
|
- 按功能分类(移液、枪头、外设等)
|
||||||
@@ -165,6 +151,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
### Step 4 — 写 SKILL.md
|
### Step 4 — 写 SKILL.md
|
||||||
|
|
||||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||||
|
|
||||||
- 设备名称
|
- 设备名称
|
||||||
- Action 数量
|
- Action 数量
|
||||||
- 目录列表
|
- 目录列表
|
||||||
@@ -172,42 +159,77 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||||
|
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
|
||||||
|
|
||||||
API 模板结构:
|
API 模板结构:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## 设备信息
|
## 设备信息
|
||||||
|
|
||||||
- device_id, Python 源码路径, 设备类名
|
- device_id, Python 源码路径, 设备类名
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
- ak/sk → AUTH, --addr → BASE URL
|
- ak/sk → AUTH, --addr → BASE URL
|
||||||
|
|
||||||
## 请求约定
|
## 请求约定
|
||||||
|
|
||||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||||
|
|
||||||
## Session State
|
## Session State
|
||||||
|
|
||||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
|
||||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||||
# - #3 创建节点 POST /edge/workflow/node
|
|
||||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
|
||||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
# - #3 创建节点 POST /edge/workflow/node
|
||||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
|
||||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||||
# - #7 批量创建边 POST /lab/workflow/edges
|
|
||||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
|
||||||
# - #9 运行设备单动作 POST /lab/mcp/run/action
|
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||||
|
|
||||||
|
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||||
|
|
||||||
|
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||||
|
|
||||||
|
# - #7 批量创建边 POST /lab/workflow/edges
|
||||||
|
|
||||||
|
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||||
|
|
||||||
|
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||||
|
|
||||||
|
# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
|
||||||
|
|
||||||
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
||||||
|
|
||||||
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
||||||
|
|
||||||
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||||
|
|
||||||
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
||||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
|
||||||
|
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
||||||
|
|
||||||
|
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
|
||||||
|
|
||||||
|
# 返回 res_template_uuid,用于 #15 创建物料时的必填字段
|
||||||
|
|
||||||
|
# - #15 创建物料节点 POST /edge/material/node
|
||||||
|
|
||||||
|
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
|
||||||
|
|
||||||
|
# - #16 更新物料节点 PUT /edge/material/node
|
||||||
|
|
||||||
|
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
|
||||||
|
|
||||||
## Placeholder Slot 填写规则
|
## Placeholder Slot 填写规则
|
||||||
|
|
||||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||||
@@ -217,13 +239,15 @@ API 模板结构:
|
|||||||
- 列出本设备所有 Slot 字段、类型及含义
|
- 列出本设备所有 Slot 字段、类型及含义
|
||||||
|
|
||||||
## 渐进加载策略
|
## 渐进加载策略
|
||||||
|
|
||||||
## 完整工作流 Checklist
|
## 完整工作流 Checklist
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 5 — 验证
|
### Step 5 — 验证
|
||||||
|
|
||||||
检查文件完整性:
|
检查文件完整性:
|
||||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
|
|
||||||
|
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
|
||||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||||
@@ -272,92 +296,48 @@ API 模板结构:
|
|||||||
|
|
||||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
||||||
|
|
||||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||||
|---------------|-----------|---------|---------|
|
| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
|
||||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
||||||
|
|
||||||
### ResourceSlot(`unilabos_resources`)
|
### ResourceSlot(`unilabos_resources`)
|
||||||
|
|
||||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||||
|
|
||||||
```json
|
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
|
||||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||||
```
|
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
|
||||||
|
|
||||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
|
||||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
|
||||||
- `id` 本身是从 parent 计算的路径格式
|
|
||||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
|
||||||
|
|
||||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
### DeviceSlot / NodeSlot / ClassSlot
|
||||||
|
|
||||||
### DeviceSlot(`unilabos_devices`)
|
- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
|
||||||
|
- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
|
||||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
|
||||||
|
|
||||||
```
|
|
||||||
"/host_node"
|
|
||||||
"/bioyond_cell/reaction_station"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
|
||||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
|
||||||
|
|
||||||
### NodeSlot(`unilabos_nodes`)
|
|
||||||
|
|
||||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
|
||||||
|
|
||||||
```
|
|
||||||
"/PRCXI/PRCXI_Deck"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
|
||||||
|
|
||||||
### ClassSlot(`unilabos_class`)
|
|
||||||
|
|
||||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
|
||||||
|
|
||||||
```
|
|
||||||
"container"
|
|
||||||
```
|
|
||||||
|
|
||||||
### FormulationSlot(`unilabos_formulation`)
|
### FormulationSlot(`unilabos_formulation`)
|
||||||
|
|
||||||
描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**:
|
描述**液体配方**:向哪些容器中加入哪些液体及体积。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"sample_uuid": "",
|
"sample_uuid": "",
|
||||||
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
"well_name": "bottle_A1",
|
||||||
"liquids": [
|
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
|
||||||
{ "name": "LiPF6", "volume": 0.6 },
|
|
||||||
{ "name": "DMC", "volume": 1.2 }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 字段说明
|
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
|
||||||
|
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL)
|
||||||
| 字段 | 类型 | 说明 |
|
- `sample_uuid` — 样品 UUID,无样品传 `""`
|
||||||
|------|------|------|
|
- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息
|
||||||
| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` |
|
|
||||||
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
|
|
||||||
| `liquids` | array | 要加入的液体列表 |
|
|
||||||
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
|
|
||||||
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) |
|
|
||||||
|
|
||||||
#### 填写规则
|
|
||||||
|
|
||||||
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
|
|
||||||
- 每个数组元素代表一个目标容器的配方
|
|
||||||
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
|
|
||||||
- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
|
|
||||||
|
|
||||||
### 通过 API #12 获取资源树
|
### 通过 API #12 获取资源树
|
||||||
|
|
||||||
@@ -365,7 +345,147 @@ API 模板结构:
|
|||||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||||
```
|
```
|
||||||
|
|
||||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"nodes": [
|
||||||
|
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
|
||||||
|
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
|
||||||
|
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
|
||||||
|
],
|
||||||
|
"edges": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`
|
||||||
|
- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等)
|
||||||
|
- `parent` 为父节点名称(空字符串表示顶级)
|
||||||
|
- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点
|
||||||
|
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
|
||||||
|
|
||||||
|
## 物料管理 API
|
||||||
|
|
||||||
|
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
|
||||||
|
|
||||||
|
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。
|
||||||
|
|
||||||
|
### API #14 — 按名称查询物料模板
|
||||||
|
|
||||||
|
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
| ---------- | ------ | -------------------------------- |
|
||||||
|
| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) |
|
||||||
|
| `name` | **是** | 物料模板名称(如 `"container"`) |
|
||||||
|
|
||||||
|
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。
|
||||||
|
|
||||||
|
模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
|
||||||
|
|
||||||
|
### API #15 — 创建物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '<request_body>'
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"name": "my_custom_bottle",
|
||||||
|
"display_name": "自定义瓶子",
|
||||||
|
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"type": "",
|
||||||
|
"init_param_data": {},
|
||||||
|
"schema": {},
|
||||||
|
"data": {
|
||||||
|
"liquids": [["water", 1000, "uL"]],
|
||||||
|
"max_volume": 50000
|
||||||
|
},
|
||||||
|
"plate_well_datas": {},
|
||||||
|
"plate_reagent_datas": {},
|
||||||
|
"pose": {},
|
||||||
|
"model": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||||
|
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
|
||||||
|
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
|
||||||
|
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
|
||||||
|
| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) |
|
||||||
|
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
|
||||||
|
| `type` | 否 | string | 从模板继承 | 节点类型 |
|
||||||
|
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
|
||||||
|
| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 |
|
||||||
|
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
|
||||||
|
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
|
||||||
|
| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 |
|
||||||
|
| `pose` | 否 | object | 用户指定 | 位姿信息 |
|
||||||
|
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
|
||||||
|
|
||||||
|
#### container 的 `data` 格式
|
||||||
|
|
||||||
|
> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
|
||||||
|
"max_volume": 50000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
|
||||||
|
- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL
|
||||||
|
|
||||||
|
### API #16 — 更新物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '<request_body>'
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"display_name": "新显示名称",
|
||||||
|
"description": "新描述",
|
||||||
|
"init_param_data": {},
|
||||||
|
"data": {},
|
||||||
|
"pose": {},
|
||||||
|
"schema": {},
|
||||||
|
"extra": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||||
|
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
|
||||||
|
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
|
||||||
|
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
|
||||||
|
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
|
||||||
|
| `description` | 否 | string | 用户指定 | 更新描述 |
|
||||||
|
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
|
||||||
|
| `data` | 否 | object | 用户指定 | 更新节点数据 |
|
||||||
|
| `pose` | 否 | object | 用户指定 | 更新位姿 |
|
||||||
|
| `schema` | 否 | object | 用户指定 | 更新 schema |
|
||||||
|
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
|
||||||
|
|
||||||
|
> 只传需要更新的字段,未传的字段保持不变。
|
||||||
|
|
||||||
## 最终目录结构
|
## 最终目录结构
|
||||||
|
|
||||||
|
|||||||
251
.cursor/skills/host-node/SKILL.md
Normal file
251
.cursor/skills/host-node/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
name: host-node
|
||||||
|
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Host Node API Skill
|
||||||
|
|
||||||
|
## 设备信息
|
||||||
|
|
||||||
|
- **device_id**: `host_node`
|
||||||
|
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
|
||||||
|
- **设备类**: `HostNode`
|
||||||
|
- **动作数**: 4(`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`)
|
||||||
|
|
||||||
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
|
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||||
|
|
||||||
|
### 1. ak / sk → AUTH
|
||||||
|
|
||||||
|
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||||
|
|
||||||
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
|
| `--addr` 值 | BASE |
|
||||||
|
| ------------ | ----------------------------------- |
|
||||||
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
|
确认后设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
AUTH="Authorization: Lab <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**两项全部就绪后才可发起 API 请求。**
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||||
|
|
||||||
|
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||||
|
- `device_name` — `host_node`
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||||
|
|
||||||
|
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||||
|
|
||||||
|
### 2. 创建工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||||
|
|
||||||
|
### 3. 创建节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `resource_template_name` 固定为 `host_node`
|
||||||
|
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`)
|
||||||
|
|
||||||
|
### 4. 删除节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 更新节点参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||||
|
|
||||||
|
### 6. 查询节点 handles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 批量创建边
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 启动工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. 运行设备单动作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||||
|
|
||||||
|
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||||
|
|
||||||
|
#### action_type 速查表
|
||||||
|
|
||||||
|
| action | action_type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `test_latency` | `UniLabJsonCommand` |
|
||||||
|
| `create_resource` | `ResourceCreateFromOuterEasy` |
|
||||||
|
| `auto-test_resource` | `UniLabJsonCommand` |
|
||||||
|
| `manual_confirm` | `UniLabJsonCommand` |
|
||||||
|
|
||||||
|
### 10. 查询任务状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. 运行工作流单节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuid":"<node_uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. 获取资源树(物料信息)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||||
|
|
||||||
|
### 13. 获取工作流模板详情
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||||
|
|
||||||
|
### 14. 按名称查询物料模板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||||
|
|
||||||
|
### 15. 创建物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 16. 更新物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Placeholder Slot 填写规则
|
||||||
|
|
||||||
|
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||||
|
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||||
|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||||
|
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||||
|
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||||
|
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||||
|
|
||||||
|
### host_node 设备的 Slot 字段表
|
||||||
|
|
||||||
|
| Action | 字段 | Slot 类型 | 说明 |
|
||||||
|
| ----------------- | ----------- | ------------ | ------------------------------ |
|
||||||
|
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
|
||||||
|
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
|
||||||
|
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
|
||||||
|
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
|
||||||
|
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
|
||||||
|
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
|
||||||
|
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
|
||||||
|
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 渐进加载策略
|
||||||
|
|
||||||
|
1. **SKILL.md**(本文件)— API 端点 + session state 管理
|
||||||
|
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
|
||||||
|
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流 Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
Task Progress:
|
||||||
|
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||||
|
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||||
|
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||||
|
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||||
|
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
|
||||||
|
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||||
|
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||||
|
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||||
|
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||||
|
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||||
|
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||||
|
```
|
||||||
58
.cursor/skills/host-node/action-index.md
Normal file
58
.cursor/skills/host-node/action-index.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Action Index — host_node
|
||||||
|
|
||||||
|
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 资源管理
|
||||||
|
|
||||||
|
### `create_resource`
|
||||||
|
|
||||||
|
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
|
||||||
|
|
||||||
|
- **action_type**: `ResourceCreateFromOuterEasy`
|
||||||
|
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
|
||||||
|
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
|
||||||
|
- **占位符字段**:
|
||||||
|
- `res_id` — **ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
|
||||||
|
- `device_id` — **DeviceSlot**,填路径字符串如 `"/host_node"`
|
||||||
|
- `parent` — **NodeSlot**,填路径字符串如 `"/workstation/deck"`
|
||||||
|
- `class_name` — **ClassSlot**,填类名如 `"container"`
|
||||||
|
|
||||||
|
### `auto-test_resource`
|
||||||
|
|
||||||
|
测试资源系统,返回当前资源树和设备列表
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
|
||||||
|
- **可选参数**: `resource`, `resources`, `device`, `devices`
|
||||||
|
- **占位符字段**:
|
||||||
|
- `resource` — **ResourceSlot**,单个物料节点 `{id, name, uuid}`
|
||||||
|
- `resources` — **ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
|
||||||
|
- `device` — **DeviceSlot**,设备路径字符串
|
||||||
|
- `devices` — **DeviceSlot**,设备路径字符串
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统工具
|
||||||
|
|
||||||
|
### `test_latency`
|
||||||
|
|
||||||
|
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
|
||||||
|
- **参数**: 无(零参数调用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人工确认
|
||||||
|
|
||||||
|
### `manual_confirm`
|
||||||
|
|
||||||
|
创建人工确认节点,等待用户手动确认后继续
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||||
|
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
|
||||||
|
- **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||||
93
.cursor/skills/host-node/actions/create_resource.json
Normal file
93
.cursor/skills/host-node/actions/create_resource.json
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"type": "ResourceCreateFromOuterEasy",
|
||||||
|
"goal": {
|
||||||
|
"res_id": "res_id",
|
||||||
|
"class_name": "class_name",
|
||||||
|
"parent": "parent",
|
||||||
|
"device_id": "device_id",
|
||||||
|
"bind_locations": "bind_locations",
|
||||||
|
"liquid_input_slot": "liquid_input_slot[]",
|
||||||
|
"liquid_type": "liquid_type[]",
|
||||||
|
"liquid_volume": "liquid_volume[]",
|
||||||
|
"slot_on_deck": "slot_on_deck"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"res_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"class_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bind_locations": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "bind_locations",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"liquid_input_slot": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liquid_type": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liquid_volume": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"slot_on_deck": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"res_id": "unilabos_resources",
|
||||||
|
"device_id": "unilabos_devices",
|
||||||
|
"parent": "unilabos_nodes",
|
||||||
|
"class_name": "unilabos_class"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"res_id": "unilabos_resources",
|
||||||
|
"device_id": "unilabos_devices",
|
||||||
|
"parent": "unilabos_nodes",
|
||||||
|
"class_name": "unilabos_class"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
.cursor/skills/host-node/actions/manual_confirm.json
Normal file
32
.cursor/skills/host-node/actions/manual_confirm.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"timeout_seconds": "timeout_seconds",
|
||||||
|
"assignee_user_ids": "assignee_user_ids"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timeout_seconds": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"assignee_user_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"timeout_seconds",
|
||||||
|
"assignee_user_ids"
|
||||||
|
],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
.cursor/skills/host-node/actions/test_latency.json
Normal file
11
.cursor/skills/host-node/actions/test_latency.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
255
.cursor/skills/host-node/actions/test_resource.json
Normal file
255
.cursor/skills/host-node/actions/test_resource.json
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"resource": "resource",
|
||||||
|
"resources": "resources",
|
||||||
|
"device": "device",
|
||||||
|
"devices": "devices"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resource": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "resource"
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "resources"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "device reference"
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "device reference"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"resources": "unilabos_resources",
|
||||||
|
"device": "unilabos_devices",
|
||||||
|
"devices": "unilabos_devices"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"resources": "unilabos_resources",
|
||||||
|
"device": "unilabos_devices",
|
||||||
|
"devices": "unilabos_devices"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: submit-agent-result
|
name: submit-agent-result
|
||||||
description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
||||||
---
|
---
|
||||||
|
|
||||||
# 提交历史实验记录指南
|
# Uni-Lab 提交历史实验记录指南
|
||||||
|
|
||||||
通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||||
|
|
||||||
|
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
@@ -18,23 +20,26 @@ description: Submit historical experiment results (agent_result) to Uni-Lab note
|
|||||||
生成 AUTH token:
|
生成 AUTH token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||||
```
|
```
|
||||||
|
|
||||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`。
|
输出即为 token 值,拼接为 `Authorization: Lab <token>`(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
| `--addr` 值 | BASE |
|
||||||
|-------------|------|
|
| ------------ | ----------------------------------- |
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
确认后设置:
|
确认后设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE="<根据 addr 确定的 URL>"
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,6 +50,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
|||||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||||
|
|
||||||
如果用户不记得,可提示:
|
如果用户不记得,可提示:
|
||||||
|
|
||||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||||
- 或通过平台页面查找对应的 notebook
|
- 或通过平台页面查找对应的 notebook
|
||||||
|
|
||||||
@@ -54,11 +60,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,
|
|||||||
|
|
||||||
用户需要提供实验结果数据,支持以下方式:
|
用户需要提供实验结果数据,支持以下方式:
|
||||||
|
|
||||||
| 方式 | 说明 |
|
| 方式 | 说明 |
|
||||||
|------|------|
|
| --------- | ----------------------------------------------- |
|
||||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||||
|
|
||||||
**四项全部就绪后才可开始。**
|
**四项全部就绪后才可开始。**
|
||||||
|
|
||||||
@@ -90,7 +96,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|||||||
返回:
|
返回:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||||
```
|
```
|
||||||
|
|
||||||
记住 `data.uuid` 为 `lab_uuid`。
|
记住 `data.uuid` 为 `lab_uuid`。
|
||||||
@@ -121,42 +127,45 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
|||||||
|
|
||||||
#### 必要字段
|
#### 必要字段
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| --------------- | ------------- | ------------------------------------------- |
|
||||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||||
|
|
||||||
#### agent_result 内容格式
|
#### agent_result 内容格式
|
||||||
|
|
||||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||||
|
|
||||||
**简单键值对**:
|
**简单键值对**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"avg_rtt_ms": 12.5,
|
"avg_rtt_ms": 12.5,
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"test_count": 5
|
"test_count": 5
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**包含嵌套结构**:
|
**包含嵌套结构**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"summary": {"total": 100, "passed": 98, "failed": 2},
|
"summary": { "total": 100, "passed": 98, "failed": 2 },
|
||||||
"measurements": [
|
"measurements": [
|
||||||
{"sample_id": "S001", "value": 3.14, "unit": "mg/mL"},
|
{ "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
|
||||||
{"sample_id": "S002", "value": 2.71, "unit": "mg/mL"}
|
{ "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**从 CSV 文件导入**(脚本自动转换):
|
**从 CSV 文件导入**(脚本自动转换):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"experiment_data": [
|
"experiment_data": [
|
||||||
{"温度": 25, "压力": 101.3, "产率": 0.85},
|
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
|
||||||
{"温度": 30, "压力": 101.3, "产率": 0.91}
|
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -178,22 +187,22 @@ python scripts/prepare_agent_result.py \
|
|||||||
[--output <output.json>]
|
[--output <output.json>]
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 必选 | 说明 |
|
| 参数 | 必选 | 说明 |
|
||||||
|------|------|------|
|
| ----------------- | ---------- | ----------------------------------------------- |
|
||||||
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
||||||
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
||||||
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
||||||
| `--base` | 提交时必选 | API base URL |
|
| `--base` | 提交时必选 | API base URL |
|
||||||
| `--submit` | 否 | 加上此标志则直接提交到云端 |
|
| `--submit` | 否 | 加上此标志则直接提交到云端 |
|
||||||
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) |
|
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) |
|
||||||
|
|
||||||
### 文件合并规则
|
### 文件合并规则
|
||||||
|
|
||||||
| 文件类型 | 合并方式 |
|
| 文件类型 | 合并方式 |
|
||||||
|----------|----------|
|
| --------------------- | -------------------------------------------- |
|
||||||
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
||||||
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
||||||
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
||||||
|
|
||||||
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
||||||
|
|
||||||
@@ -210,7 +219,7 @@ python scripts/prepare_agent_result.py \
|
|||||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||||
--files results.json \
|
--files results.json \
|
||||||
--auth YTFmZDlkNGUt... \
|
--auth YTFmZDlkNGUt... \
|
||||||
--base https://uni-lab.test.bohrium.com \
|
--base https://leap-lab.test.bohrium.com \
|
||||||
--submit
|
--submit
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -272,4 +281,4 @@ Task Progress:
|
|||||||
|
|
||||||
### Q: 认证方式是 Lab 还是 Api?
|
### Q: 认证方式是 Lab 还是 Api?
|
||||||
|
|
||||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||||
|
|||||||
272
.cursor/skills/virtual-workbench/SKILL.md
Normal file
272
.cursor/skills/virtual-workbench/SKILL.md
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
---
|
||||||
|
name: virtual-workbench
|
||||||
|
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Virtual Workbench API Skill
|
||||||
|
|
||||||
|
## 设备信息
|
||||||
|
|
||||||
|
- **device_id**: `virtual_workbench`
|
||||||
|
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
|
||||||
|
- **设备类**: `VirtualWorkbench`
|
||||||
|
- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`)
|
||||||
|
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行)
|
||||||
|
|
||||||
|
### 典型工作流程
|
||||||
|
|
||||||
|
1. `prepare_materials` — 生成 A1-A5 物料(5 个 output handle)
|
||||||
|
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
|
||||||
|
3. `start_heating` — 启动加热(3 个加热台可并行)
|
||||||
|
4. `move_to_output` — 加热完成后移到输出位置 Cn
|
||||||
|
|
||||||
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
|
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||||
|
|
||||||
|
### 1. ak / sk → AUTH
|
||||||
|
|
||||||
|
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||||
|
|
||||||
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
|
| `--addr` 值 | BASE |
|
||||||
|
| ------------ | ----------------------------------- |
|
||||||
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
|
确认后设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
AUTH="Authorization: Lab <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**两项全部就绪后才可发起 API 请求。**
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||||
|
- `device_name` — `virtual_workbench`
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||||
|
|
||||||
|
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||||
|
|
||||||
|
### 2. 创建工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||||
|
|
||||||
|
### 3. 创建节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `resource_template_name` 固定为 `virtual_workbench`
|
||||||
|
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`)
|
||||||
|
|
||||||
|
### 4. 删除节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 更新节点参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||||
|
|
||||||
|
### 6. 查询节点 handles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 批量创建边
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 启动工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. 运行设备单动作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||||
|
|
||||||
|
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||||
|
|
||||||
|
#### action_type 速查表
|
||||||
|
|
||||||
|
| action | action_type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `auto-prepare_materials` | `UniLabJsonCommand` |
|
||||||
|
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
|
||||||
|
| `auto-start_heating` | `UniLabJsonCommand` |
|
||||||
|
| `auto-move_to_output` | `UniLabJsonCommand` |
|
||||||
|
| `transfer` | `UniLabJsonCommandAsync` |
|
||||||
|
| `manual_confirm` | `UniLabJsonCommand` |
|
||||||
|
|
||||||
|
### 10. 查询任务状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. 运行工作流单节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuid":"<node_uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. 获取资源树(物料信息)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||||
|
|
||||||
|
### 13. 获取工作流模板详情
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||||
|
|
||||||
|
### 14. 按名称查询物料模板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||||
|
|
||||||
|
### 15. 创建物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 16. 更新物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Placeholder Slot 填写规则
|
||||||
|
|
||||||
|
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||||
|
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||||
|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||||
|
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||||
|
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||||
|
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||||
|
|
||||||
|
### virtual_workbench 设备的 Slot 字段表
|
||||||
|
|
||||||
|
| Action | 字段 | Slot 类型 | 说明 |
|
||||||
|
| ----------------- | ---------------- | ------------ | -------------------- |
|
||||||
|
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
|
||||||
|
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
|
||||||
|
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
|
||||||
|
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
|
||||||
|
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
|
||||||
|
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
|
||||||
|
|
||||||
|
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 渐进加载策略
|
||||||
|
|
||||||
|
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
|
||||||
|
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
|
||||||
|
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流 Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
Task Progress:
|
||||||
|
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||||
|
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||||
|
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||||
|
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||||
|
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
|
||||||
|
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||||
|
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||||
|
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||||
|
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||||
|
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||||
|
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 典型 5 物料并发加热工作流示例
|
||||||
|
|
||||||
|
```
|
||||||
|
prepare_materials (count=5)
|
||||||
|
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
|
||||||
|
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
|
||||||
|
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
|
||||||
|
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
|
||||||
|
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
|
||||||
|
```
|
||||||
|
|
||||||
|
创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。
|
||||||
76
.cursor/skills/virtual-workbench/action-index.md
Normal file
76
.cursor/skills/virtual-workbench/action-index.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Action Index — virtual_workbench
|
||||||
|
|
||||||
|
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 物料准备
|
||||||
|
|
||||||
|
### `auto-prepare_materials`
|
||||||
|
|
||||||
|
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
|
||||||
|
- **可选参数**: `count`(物料数量,默认 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 机械臂 & 加热台操作
|
||||||
|
|
||||||
|
### `auto-move_to_heating_station`
|
||||||
|
|
||||||
|
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
|
||||||
|
- **核心参数**: `material_number`(物料编号,integer)
|
||||||
|
|
||||||
|
### `auto-start_heating`
|
||||||
|
|
||||||
|
启动指定加热台的加热程序(可并行,3 个加热台同时工作)
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
|
||||||
|
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||||
|
|
||||||
|
### `auto-move_to_output`
|
||||||
|
|
||||||
|
将加热完成的物料从加热台移动到输出位置 Cn
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
|
||||||
|
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 物料转移
|
||||||
|
|
||||||
|
### `transfer`
|
||||||
|
|
||||||
|
异步转移物料到目标设备(通过 ROS 资源转移)
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommandAsync`
|
||||||
|
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
|
||||||
|
- **核心参数**: `resource`, `target_device`, `mount_resource`
|
||||||
|
- **占位符字段**:
|
||||||
|
- `resource` — **ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
|
||||||
|
- `target_device` — **DeviceSlot**,目标设备路径字符串
|
||||||
|
- `mount_resource` — **ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人工确认
|
||||||
|
|
||||||
|
### `manual_confirm`
|
||||||
|
|
||||||
|
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||||
|
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
|
||||||
|
- **占位符字段**:
|
||||||
|
- `resource` — **ResourceSlot**,物料数组
|
||||||
|
- `target_device` — **DeviceSlot**,目标设备路径
|
||||||
|
- `mount_resource` — **ResourceSlot**,目标孔位数组
|
||||||
|
- `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||||
270
.cursor/skills/virtual-workbench/actions/manual_confirm.json
Normal file
270
.cursor/skills/virtual-workbench/actions/manual_confirm.json
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"resource": "resource",
|
||||||
|
"target_device": "target_device",
|
||||||
|
"mount_resource": "mount_resource",
|
||||||
|
"timeout_seconds": "timeout_seconds",
|
||||||
|
"assignee_user_ids": "assignee_user_ids"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resource": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "resource"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"target_device": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "device reference"
|
||||||
|
},
|
||||||
|
"mount_resource": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "mount_resource"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"timeout_seconds": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"assignee_user_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"resource",
|
||||||
|
"target_device",
|
||||||
|
"mount_resource",
|
||||||
|
"timeout_seconds",
|
||||||
|
"assignee_user_ids"
|
||||||
|
],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"target_device": "unilabos_devices",
|
||||||
|
"mount_resource": "unilabos_resources",
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"target_device": "unilabos_devices",
|
||||||
|
"mount_resource": "unilabos_resources",
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"material_number": "material_number"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"material_number": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"material_number"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
24
.cursor/skills/virtual-workbench/actions/move_to_output.json
Normal file
24
.cursor/skills/virtual-workbench/actions/move_to_output.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"station_id": "station_id",
|
||||||
|
"material_number": "material_number"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"station_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"material_number": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"station_id",
|
||||||
|
"material_number"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"count": "count"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
|
"goal_default": {
|
||||||
|
"count": 5
|
||||||
|
},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
24
.cursor/skills/virtual-workbench/actions/start_heating.json
Normal file
24
.cursor/skills/virtual-workbench/actions/start_heating.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"station_id": "station_id",
|
||||||
|
"material_number": "material_number"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"station_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"material_number": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"station_id",
|
||||||
|
"material_number"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
255
.cursor/skills/virtual-workbench/actions/transfer.json
Normal file
255
.cursor/skills/virtual-workbench/actions/transfer.json
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommandAsync",
|
||||||
|
"goal": {
|
||||||
|
"resource": "resource",
|
||||||
|
"target_device": "target_device",
|
||||||
|
"mount_resource": "mount_resource"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resource": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "resource"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"target_device": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "device reference"
|
||||||
|
},
|
||||||
|
"mount_resource": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "mount_resource"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"resource",
|
||||||
|
"target_device",
|
||||||
|
"mount_resource"
|
||||||
|
],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"target_device": "unilabos_devices",
|
||||||
|
"mount_resource": "unilabos_resources"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"target_device": "unilabos_devices",
|
||||||
|
"mount_resource": "unilabos_resources"
|
||||||
|
}
|
||||||
|
}
|
||||||
188
.cursorignore
Normal file
188
.cursorignore
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Uni-Lab-OS Cursor Ignore 配置,控制 Cursor AI 的文件索引范围
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ==================== 敏感配置文件 ====================
|
||||||
|
# 本地配置(可能包含密钥)
|
||||||
|
**/local_config.py
|
||||||
|
test_config.py
|
||||||
|
local_test*.py
|
||||||
|
|
||||||
|
# 环境变量和密钥
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
**/.certs/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
credentials.json
|
||||||
|
secrets.yaml
|
||||||
|
|
||||||
|
# ==================== 二进制和 3D 模型文件 ====================
|
||||||
|
# 3D 模型文件(无需索引)
|
||||||
|
*.stl
|
||||||
|
*.dae
|
||||||
|
*.glb
|
||||||
|
*.gltf
|
||||||
|
*.obj
|
||||||
|
*.fbx
|
||||||
|
*.blend
|
||||||
|
|
||||||
|
# URDF/Xacro 机器人描述文件(大型XML)
|
||||||
|
*.xacro
|
||||||
|
|
||||||
|
# 图片文件
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.gif
|
||||||
|
*.webp
|
||||||
|
*.ico
|
||||||
|
*.svg
|
||||||
|
*.bmp
|
||||||
|
|
||||||
|
# 压缩包
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
*.bz2
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
|
|
||||||
|
# ==================== Python 生成文件 ====================
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
*.pyd
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# ==================== IDE 和编辑器 ====================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.#*
|
||||||
|
|
||||||
|
# ==================== 测试和覆盖率 ====================
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
|
||||||
|
# ==================== 虚拟环境 ====================
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# ==================== ROS 2 生成文件 ====================
|
||||||
|
# ROS 构建目录
|
||||||
|
build/
|
||||||
|
install/
|
||||||
|
log/
|
||||||
|
logs/
|
||||||
|
devel/
|
||||||
|
|
||||||
|
# ROS 消息生成
|
||||||
|
msg_gen/
|
||||||
|
srv_gen/
|
||||||
|
msg/*Action.msg
|
||||||
|
msg/*ActionFeedback.msg
|
||||||
|
msg/*ActionGoal.msg
|
||||||
|
msg/*ActionResult.msg
|
||||||
|
msg/*Feedback.msg
|
||||||
|
msg/*Goal.msg
|
||||||
|
msg/*Result.msg
|
||||||
|
msg/_*.py
|
||||||
|
srv/_*.py
|
||||||
|
build_isolated/
|
||||||
|
devel_isolated/
|
||||||
|
|
||||||
|
# ROS 动态配置
|
||||||
|
*.cfgc
|
||||||
|
/cfg/cpp/
|
||||||
|
/cfg/*.py
|
||||||
|
|
||||||
|
# ==================== 项目特定目录 ====================
|
||||||
|
# 工作数据目录
|
||||||
|
unilabos_data/
|
||||||
|
|
||||||
|
# 临时和输出目录
|
||||||
|
temp/
|
||||||
|
output/
|
||||||
|
cursor_docs/
|
||||||
|
configs/
|
||||||
|
|
||||||
|
# 文档构建
|
||||||
|
docs/_build/
|
||||||
|
/site
|
||||||
|
|
||||||
|
# ==================== 大型数据文件 ====================
|
||||||
|
# 点云数据
|
||||||
|
*.pcd
|
||||||
|
|
||||||
|
# GraphML 图形文件
|
||||||
|
*.graphml
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Jupyter 检查点
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
|
||||||
|
# ==================== 设备网格资源 ====================
|
||||||
|
# 3D 网格文件目录(包含大量 STL/DAE 文件)
|
||||||
|
unilabos/device_mesh/devices/**/*.stl
|
||||||
|
unilabos/device_mesh/devices/**/*.dae
|
||||||
|
unilabos/device_mesh/resources/**/*.stl
|
||||||
|
unilabos/device_mesh/resources/**/*.glb
|
||||||
|
unilabos/device_mesh/resources/**/*.xacro
|
||||||
|
|
||||||
|
# RViz 配置
|
||||||
|
*.rviz
|
||||||
|
|
||||||
|
# ==================== 系统文件 ====================
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# ==================== 锁文件 ====================
|
||||||
|
poetry.lock
|
||||||
|
Pipfile.lock
|
||||||
|
pdm.lock
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# ==================== 类型检查缓存 ====================
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
.pytype/
|
||||||
|
.pyre/
|
||||||
|
pyrightconfig.json
|
||||||
|
|
||||||
|
# ==================== 其他 ====================
|
||||||
|
# Catkin
|
||||||
|
CATKIN_IGNORE
|
||||||
|
|
||||||
|
# Eclipse/Qt
|
||||||
|
.project
|
||||||
|
.cproject
|
||||||
|
CMakeLists.txt.user
|
||||||
|
*.user
|
||||||
|
qtcreator-*
|
||||||
11
.github/copilot-instructions.md
vendored
Normal file
11
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
## 设备接入
|
||||||
|
|
||||||
|
当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`。
|
||||||
|
该指南包含完整的模板和已有设备接口参考。
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
- 动作方法的参数名是接口契约,不可重命名
|
||||||
|
- `status` 字符串必须与同类已有设备一致
|
||||||
|
- `self.data` 必须在 `__init__` 中预填充所有属性字段
|
||||||
|
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()`
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -251,7 +251,4 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
|||||||
*.bz2
|
*.bz2
|
||||||
test_config.py
|
test_config.py
|
||||||
|
|
||||||
# Local config files with secrets
|
|
||||||
yibin_coin_cell_only_config.json
|
|
||||||
yibin_electrolyte_config.json
|
|
||||||
yibin_electrolyte_only_config.json
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
# CSV 导出功能变更概要
|
|
||||||
|
|
||||||
## 修改的文件
|
|
||||||
|
|
||||||
### 1. [bioyond_cell_workstation.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)
|
|
||||||
|
|
||||||
#### 新增导入
|
|
||||||
- `import csv` 和 `import os`(L14-15)
|
|
||||||
|
|
||||||
#### 新增方法
|
|
||||||
|
|
||||||
| 方法 | 功能 |
|
|
||||||
|------|------|
|
|
||||||
| `_extract_prep_bottle_from_report` | 从 order_finish 报文提取**配液瓶**信息(每订单最多1个) |
|
|
||||||
| `_extract_vial_bottles_from_report` | 从 order_finish 报文提取**分液瓶**信息(每订单可多个,返回数组) |
|
|
||||||
| `_export_order_csv` | 汇总所有信息写入 CSV 文件 |
|
|
||||||
|
|
||||||
#### 配液瓶筛选逻辑 (`_extract_prep_bottle_from_report`)
|
|
||||||
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
|
|
||||||
- `locationId` 以 `3a19deae-2c7a-` 开头(手动传递窗)
|
|
||||||
- LIMS API 二次确认:`typeName` 含"配液瓶(小)"或"配液瓶(大)"
|
|
||||||
|
|
||||||
#### 分液瓶筛选逻辑 (`_extract_vial_bottles_from_report`)
|
|
||||||
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
|
|
||||||
- `locationId` 以 `3a19debc-84b5-` 或 `3a19debe-5200` 开头(自动堆栈-左/右)
|
|
||||||
- LIMS API 二次确认:`typeName` 为"5ml分液瓶"或"20ml分液瓶"
|
|
||||||
- **返回数组**,支持 1×5ml + n×20ml 的组合
|
|
||||||
|
|
||||||
#### 修改的方法
|
|
||||||
|
|
||||||
| 方法 | 变更 |
|
|
||||||
|------|------|
|
|
||||||
| `_submit_and_wait_orders` | 新增配液瓶+分液瓶提取步骤,将 `prep_bottles` 和 `vial_bottles` 存入 `final_result` |
|
|
||||||
| `create_orders` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
|
|
||||||
| `create_orders_formulation` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
|
|
||||||
|
|
||||||
#### CSV 输出格式
|
|
||||||
```
|
|
||||||
orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间
|
|
||||||
```
|
|
||||||
- 单个分液瓶时直接写值;多个分液瓶时类型和二维码用 JSON 数组表示
|
|
||||||
- CSV 编码使用 `utf-8-sig`(兼容 Excel 打开)
|
|
||||||
- `csv_export_path` 默认为空字符串,不传则不导出(向后兼容)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. [bioyond_cell.yaml](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/registry/devices/bioyond_cell.yaml)
|
|
||||||
|
|
||||||
为两个 action 注册了 `csv_export_path` 参数:
|
|
||||||
|
|
||||||
- `auto-create_orders`: `goal_default` + `schema.properties.goal.properties` 中添加 `csv_export_path`
|
|
||||||
- `auto-create_orders_formulation`: 同上
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. [coin_cell_assembly.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py) 的 CSV 改动与全流程追溯
|
|
||||||
|
|
||||||
在 `bioyond_cell_workstation.py` 的 `_submit_and_wait_orders` 最后阶段,提取 `prep_bottles`(配液瓶)和 `vial_bottles`(分液瓶)的条码并随 `mass_ratios` 数组一起下发给各下游工站(例如扣电组装站),实现跨站的全流程配方追溯。
|
|
||||||
|
|
||||||
并在扣电站生成的 `date_xxx.csv` 中,**替换并新增**了以下列:
|
|
||||||
- 移除了原有的 `formulation_order_code` 与合并的 `formulation_ratio` 列。
|
|
||||||
- 新增 `orderName` 导出
|
|
||||||
- 新增 `prep_bottle_barcode`(奔曜传递的配液瓶二维码)
|
|
||||||
- 新增 `vial_bottle_barcodes`(奔曜传递的分液瓶二维码,多瓶时存 JSON 数组)
|
|
||||||
- 新增 `target_mass_ratio` 理论目标质量比
|
|
||||||
- 新增 `real_mass_ratio` 实际称量真实质量比
|
|
||||||
|
|
||||||
*注意:这与操作人员在手套箱内扫码传入扣电站的 `electrolyte_code` 是单独记录的,方便做数据核对。*
|
|
||||||
|
|
||||||
## 向后兼容性
|
|
||||||
- `csv_export_path` 默认值为 `""`(空字符串),现有调用不受影响
|
|
||||||
- 新增的 `prep_bottles` 和 `vial_bottles` 字段为 `final_result` 和 `mass_ratios` 内部的新增附属字段,不破坏现有数据结构。
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# 变更说明 2026-03-24
|
|
||||||
|
|
||||||
## 问题背景
|
|
||||||
|
|
||||||
`BioyondElectrolyteDeck`(原 `BIOYOND_YB_Deck`)迁移后,前端物料未能正常上传/同步。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 修复内容
|
|
||||||
|
|
||||||
### 1. `unilabos/resources/bioyond/decks.py`
|
|
||||||
|
|
||||||
- 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑,与旧版 `BIOYOND_YB_Deck` 保持一致
|
|
||||||
- 工厂函数 `bioyond_electrolyte_deck` 保留显式调用 `deck.setup()`,避免重复初始化
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 修复前(缺少 setup 参数,无法通过 setup=True 触发初始化)
|
|
||||||
def __init__(self, name, size_x, size_y, size_z, category):
|
|
||||||
super().__init__(...)
|
|
||||||
|
|
||||||
# 修复后
|
|
||||||
def __init__(self, name, size_x, size_y, size_z, category, setup: bool = False):
|
|
||||||
super().__init__(...)
|
|
||||||
if setup:
|
|
||||||
self.setup()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `unilabos/resources/graphio.py`
|
|
||||||
|
|
||||||
- 修复 `resource_bioyond_to_plr` 中两处 `bottle.tracker.liquids` 直接赋值导致的崩溃
|
|
||||||
- `ResourceHolder`(如枪头盒的 TipSpot 槽位)没有 `tracker` 属性,直接访问会抛出 `AttributeError`,阻断整个 Bioyond 同步流程
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 修复前
|
|
||||||
bottle.tracker.liquids = [...]
|
|
||||||
|
|
||||||
# 修复后
|
|
||||||
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
|
|
||||||
bottle.tracker.liquids = [...]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. `unilabos/app/main.py`
|
|
||||||
|
|
||||||
- 保留 `file_path is not None` 条件不变(已还原),并补充注释说明原因
|
|
||||||
- 该逻辑只在**本地文件模式**下有意义:本地 graph 文件只含设备结构,远端有已保存物料,merge 才能将两者合并
|
|
||||||
- 远端模式(`file_path=None`)下,`resource_tree_set` 和 `request_startup_json` 来自同一份数据,merge 为空操作,条件是否加 `file_path is not None` 对结果没有影响
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `unilabos/devices/workstation/bioyond_studio/station.py` ⭐ 核心修复
|
|
||||||
|
|
||||||
- 当 deck 通过反序列化创建时,不会自动调用 `setup()`,导致 `deck.children` 为空,`warehouses` 始终是 `{}`
|
|
||||||
- 增加兜底逻辑:仓库扫描后仍为空,则主动调用 `deck.setup()` 初始化仓库
|
|
||||||
- 这是导致所有物料放置失败(`warehouse '...' 在deck中不存在。可用warehouses: []`)的根本原因
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 新增兜底
|
|
||||||
if not self.deck.warehouses and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
|
||||||
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
|
|
||||||
self.deck.setup()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 补充修复 2026-03-25:依华扣电组装工站子物料未上传
|
|
||||||
|
|
||||||
### 问题
|
|
||||||
|
|
||||||
`CoinCellAssemblyWorkstation.post_init` 直接上传空 deck,未调用 `deck.setup()`,导致:
|
|
||||||
- 前端子物料(成品弹夹、料盘、瓶架等)不显示
|
|
||||||
- 运行时 `self.deck.get_resource("成品弹夹")` 抛出 `ResourceNotFoundError`
|
|
||||||
|
|
||||||
### 修复文件
|
|
||||||
|
|
||||||
**`unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`**
|
|
||||||
- `YihuaCoinCellDeck.__init__` 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑
|
|
||||||
|
|
||||||
**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`**
|
|
||||||
- `post_init` 中增加与 Bioyond 工站相同的兜底逻辑:deck 无子节点时调用 `deck.setup()` 初始化
|
|
||||||
|
|
||||||
```python
|
|
||||||
# post_init 中新增
|
|
||||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
|
||||||
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
|
|
||||||
self.deck.setup()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 联动 Bug:`MaterialPlate.create_with_holes` 构造顺序错误
|
|
||||||
|
|
||||||
**现象**:`deck.setup()` 被调用后,启动时抛出:
|
|
||||||
```
|
|
||||||
设备后初始化失败: Must specify either `ordered_items` or `ordering`.
|
|
||||||
```
|
|
||||||
|
|
||||||
**根因**:`create_with_holes` 原来的逻辑是先构造空的 `MaterialPlate` 实例,再 assign 洞位:
|
|
||||||
```python
|
|
||||||
# 旧(错误):cls(...) 时 ordered_items=None → ItemizedResource.__init__ 立即报错
|
|
||||||
plate = cls(name=name, ...) # ← 这里就崩了
|
|
||||||
holes = create_ordered_items_2d(...) # ← 根本没走到这里
|
|
||||||
for hole_name, hole in holes.items():
|
|
||||||
plate.assign_child_resource(...)
|
|
||||||
```
|
|
||||||
pylabrobot 的 `ItemizedResource.__init__` 强制要求 `ordered_items` 和 `ordering` 必须有一个不为 `None`,空构造直接失败。
|
|
||||||
|
|
||||||
**修复**:先建洞位,再作为 `ordered_items` 传给构造函数:
|
|
||||||
```python
|
|
||||||
# 新(正确):先建洞位,再一次性传入构造函数
|
|
||||||
holes = create_ordered_items_2d(klass=MaterialHole, num_items_x=4, ...)
|
|
||||||
return cls(name=name, ..., ordered_items=holes)
|
|
||||||
```
|
|
||||||
|
|
||||||
> 此 bug 此前未被触发,是因为 `deck.setup()` 从未被调用到——正是上面 `post_init` 兜底修复引出的联动问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 补充修复 2026-03-25:3→2→1 转运资源同步失败
|
|
||||||
|
|
||||||
### 问题
|
|
||||||
|
|
||||||
配液工站(Bioyond)完成分液后,调用 `transfer_3_to_2_to_1_auto` 将分液瓶板转运到扣电工站(BatteryStation)。物理 LIMS 转运成功,但数字孪生资源树同步始终失败:
|
|
||||||
```
|
|
||||||
[资源同步] ❌ 失败: 目标设备 'BatteryStation' 中未找到资源 'bottle_rack_6x2'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 根因
|
|
||||||
|
|
||||||
`_get_resource_from_device` 方法负责跨设备查找资源对象,有两个问题:
|
|
||||||
|
|
||||||
1. **原始路径完全失效**:尝试 `from unilabos.app.ros2_app import get_device_plr_resource_by_name`,但该模块不存在,`ImportError` 被 `except Exception: pass` 静默吞掉
|
|
||||||
2. **降级路径搜错地方**:遍历 `self._plr_resources`(Bioyond 自己的资源),不可能找到 BatteryStation 的 `bottle_rack_6x2`
|
|
||||||
|
|
||||||
### 修复文件
|
|
||||||
|
|
||||||
**`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`**
|
|
||||||
|
|
||||||
改用全局设备注册表 `registered_devices` 跨设备访问目标 deck:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 修复前(失效)
|
|
||||||
from unilabos.app.ros2_app import get_device_plr_resource_by_name # 模块不存在
|
|
||||||
return get_device_plr_resource_by_name(device_id, resource_name)
|
|
||||||
|
|
||||||
# 修复后
|
|
||||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
|
||||||
device_info = registered_devices.get(device_id)
|
|
||||||
if device_info is not None:
|
|
||||||
driver = device_info.get("driver_instance") # TypedDict 是 dict,必须用 .get()
|
|
||||||
if driver is not None:
|
|
||||||
deck = getattr(driver, "deck", None)
|
|
||||||
if deck is not None:
|
|
||||||
res = deck.get_resource(resource_name)
|
|
||||||
```
|
|
||||||
|
|
||||||
关键细节:`DeviceInfoType` 是 `TypedDict`(即普通 `dict`),必须用 `device_info.get("driver_instance")` 而非 `getattr(device_info, "driver_instance", None)`——后者对字典永远返回 `None`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 根本原因分析
|
|
||||||
|
|
||||||
旧版以**本地文件模式**启动(有 `graph` 文件),deck 在启动前已通过 `merge_remote_resources` 获得仓库子节点,反序列化时能正确恢复 warehouses。
|
|
||||||
|
|
||||||
新版以**远端模式**启动(`file_path=None`),deck 反序列化时没有仓库子节点,`station.py` 扫描为空,所有物料的 warehouse 匹配失败,Bioyond 同步的 16 个资源全部无法放置到对应仓库位,前端不显示。
|
|
||||||
@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
|
|||||||
|
|
||||||
**获取方式:**
|
**获取方式:**
|
||||||
|
|
||||||
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ class WSConfig:
|
|||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
|
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||||
|
|
||||||
# ROS配置
|
# ROS配置
|
||||||
class ROSConfig:
|
class ROSConfig:
|
||||||
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
|||||||
|
|
||||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||||
|
|
||||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
- `test` → `https://leap-lab.test.bohrium.com/api/v1`
|
||||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
- `uat` → `https://leap-lab.uat.bohrium.com/api/v1`
|
||||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||||
- 其他值 → 直接使用作为完整 URL
|
- 其他值 → 直接使用作为完整 URL
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
|||||||
|
|
||||||
`ak` 和 `sk` 是必需的认证参数:
|
`ak` 和 `sk` 是必需的认证参数:
|
||||||
|
|
||||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
|
||||||
2. **配置方式**:
|
2. **配置方式**:
|
||||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
||||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||||
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
|
|||||||
|
|
||||||
HTTP 客户端配置用于与云端服务通信:
|
HTTP 客户端配置用于与云端服务通信:
|
||||||
|
|
||||||
| 参数 | 类型 | 默认值 | 说明 |
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
| ------------- | ---- | -------------------------------------- | ------------ |
|
| ------------- | ---- | --------------------------------------- | ------------ |
|
||||||
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||||
|
|
||||||
**预设环境地址**:
|
**预设环境地址**:
|
||||||
|
|
||||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
|
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
|
||||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
|
||||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
|
||||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||||
|
|
||||||
### 4. ROSConfig - ROS 配置
|
### 4. ROSConfig - ROS 配置
|
||||||
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
|||||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||||
|
|
||||||
# 设置HTTP配置
|
# 设置HTTP配置
|
||||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
|
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置文件使用方法
|
## 配置文件使用方法
|
||||||
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||||
```
|
```
|
||||||
|
|
||||||
**环境变量方式:**
|
**环境变量方式:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
|
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
**命令行方式(推荐):**
|
**命令行方式(推荐):**
|
||||||
|
|||||||
1100
docs/ai_guides/add_device.md
Normal file
1100
docs/ai_guides/add_device.md
Normal file
File diff suppressed because it is too large
Load Diff
344
docs/ai_guides/agent_prompt_template.md
Normal file
344
docs/ai_guides/agent_prompt_template.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
# Uni-Lab-OS 设备接入 Agent — 提示词模板
|
||||||
|
|
||||||
|
> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。
|
||||||
|
> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统提示词模板
|
||||||
|
|
||||||
|
以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 开始复制 ↓
|
||||||
|
|
||||||
|
```
|
||||||
|
你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。
|
||||||
|
|
||||||
|
你能做的事:
|
||||||
|
- 根据用户描述,生成完整的设备驱动代码(Python)、注册表(YAML)和实验图文件(JSON)
|
||||||
|
- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式
|
||||||
|
- 诊断已有驱动代码的接口对齐问题
|
||||||
|
|
||||||
|
你不能做的事:
|
||||||
|
- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取)
|
||||||
|
- 替代真实硬件联调测试
|
||||||
|
|
||||||
|
## 知识来源
|
||||||
|
|
||||||
|
{{KNOWLEDGE_LOADING}}
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。
|
||||||
|
|
||||||
|
### 阶段 1:设备画像(交互)
|
||||||
|
|
||||||
|
向用户收集以下三个信息,可以一次性提问:
|
||||||
|
|
||||||
|
1. **设备类别** — 属于以下哪一种?
|
||||||
|
- temperature(温控)、pump_and_valve(泵阀)、motor(电机)
|
||||||
|
- heaterstirrer(加热搅拌)、balance(天平)、sensor(传感器)
|
||||||
|
- liquid_handling(液体处理)、robot_arm(机械臂)、workstation(工作站)
|
||||||
|
- virtual(虚拟设备)、custom(自定义)
|
||||||
|
- 如果是 pump_and_valve,进一步确认子类型:注射泵 / 电磁阀 / 蠕动泵
|
||||||
|
|
||||||
|
2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b)
|
||||||
|
|
||||||
|
3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟)
|
||||||
|
|
||||||
|
⏸️ **暂停:等待用户回答后继续**
|
||||||
|
|
||||||
|
### 阶段 2:指令协议收集(交互)
|
||||||
|
|
||||||
|
根据上一步确定的通信协议,引导用户提供指令信息:
|
||||||
|
|
||||||
|
- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑
|
||||||
|
- 如果用户有 **协议文档**:请用户提供文档(PDF/图片/文本),你从中解析指令格式
|
||||||
|
- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令
|
||||||
|
- 如果是 **标准协议**(Modbus 寄存器表、SCPI):请用户提供寄存器/指令映射
|
||||||
|
- 如果是 **虚拟设备**:跳过此阶段
|
||||||
|
|
||||||
|
⏸️ **暂停:确认已获取足够的指令协议信息**
|
||||||
|
|
||||||
|
### 阶段 3:确认摘要
|
||||||
|
|
||||||
|
在开始生成代码前,向用户展示你的理解摘要:
|
||||||
|
|
||||||
|
```
|
||||||
|
设备接入摘要:
|
||||||
|
- 设备名称:<name>
|
||||||
|
- 设备类别:<category>(<subtype>)
|
||||||
|
- 通信协议:<protocol>
|
||||||
|
- 指令来源:<source>
|
||||||
|
- 将要实现的属性:<list>
|
||||||
|
- 将要实现的动作:<list>
|
||||||
|
- 同类已有设备:<existing>(将对齐其接口)
|
||||||
|
```
|
||||||
|
|
||||||
|
⏸️ **暂停:用户确认"没问题"后再生成代码**
|
||||||
|
|
||||||
|
### 阶段 4:自动生成(无需暂停)
|
||||||
|
|
||||||
|
按以下顺序自动执行:
|
||||||
|
|
||||||
|
1. **对齐同类设备接口**(指南第四步)
|
||||||
|
- 查阅指南中的「现有设备接口快照」或搜索仓库注册表
|
||||||
|
- 确保所有已有设备的 status_types 和动作方法都被覆盖
|
||||||
|
- 参数名必须完全一致
|
||||||
|
|
||||||
|
2. **生成驱动代码** — `unilabos/devices/<category>/<name>.py`
|
||||||
|
|
||||||
|
3. **生成注册表** — `unilabos/registry/devices/<name>.yaml`(最小配置)
|
||||||
|
|
||||||
|
4. **生成图文件** — `unilabos/test/experiments/graph_example_<name>.json`
|
||||||
|
|
||||||
|
### 阶段 5:验证输出
|
||||||
|
|
||||||
|
生成完成后,逐项检查对齐验证清单并展示结果:
|
||||||
|
|
||||||
|
```
|
||||||
|
对齐验证清单:
|
||||||
|
- [x] 所有动作方法的参数名与已有设备完全一致
|
||||||
|
- [x] status 属性返回的字符串值与已有设备一致
|
||||||
|
- [x] 已有设备的所有 status_types 字段都有对应 @property
|
||||||
|
- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法
|
||||||
|
- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值
|
||||||
|
- [x] 串口/二进制协议的响应解析先定位帧起始标记
|
||||||
|
```
|
||||||
|
|
||||||
|
如果有未通过的项,主动修复后再展示。
|
||||||
|
|
||||||
|
## 硬约束(违反任何一条都会导致设备接入失败)
|
||||||
|
|
||||||
|
1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity)是接口契约,框架通过参数名分派调用。绝不能加后缀(如 volume_ml)、改名(如 speed_ml_s)。单位写在 docstring 中。
|
||||||
|
|
||||||
|
2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。
|
||||||
|
|
||||||
|
3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。
|
||||||
|
|
||||||
|
4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。
|
||||||
|
|
||||||
|
5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE),禁止用硬编码索引直接解析。
|
||||||
|
|
||||||
|
6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。
|
||||||
|
|
||||||
|
7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位(mL、°C、RPM),驱动内部负责转换到硬件原始值(步数、Hz、寄存器值)。
|
||||||
|
|
||||||
|
## 代码骨架参考
|
||||||
|
|
||||||
|
所有设备驱动遵循以下结构:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
import time as time_module
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
except ImportError:
|
||||||
|
BaseROS2DeviceNode = None
|
||||||
|
|
||||||
|
class MyDevice:
|
||||||
|
_ros_node: "BaseROS2DeviceNode"
|
||||||
|
|
||||||
|
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
|
||||||
|
if device_id is None and 'id' in kwargs:
|
||||||
|
device_id = kwargs.pop('id')
|
||||||
|
if config is None and 'config' in kwargs:
|
||||||
|
config = kwargs.pop('config')
|
||||||
|
self.device_id = device_id or "unknown_device"
|
||||||
|
self.config = config or {}
|
||||||
|
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||||
|
self.data = {
|
||||||
|
"status": "Idle",
|
||||||
|
# 所有 @property 的键都必须在此预填充
|
||||||
|
}
|
||||||
|
|
||||||
|
def post_init(self, ros_node: "BaseROS2DeviceNode"):
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
async def initialize(self) -> bool:
|
||||||
|
self.data["status"] = "Idle"
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def cleanup(self) -> bool:
|
||||||
|
self.data["status"] = "Offline"
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return self.data.get("status", "Idle")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注册表最小配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
my_device:
|
||||||
|
class:
|
||||||
|
module: unilabos.devices.<category>.<file>:MyDevice
|
||||||
|
type: python
|
||||||
|
```
|
||||||
|
|
||||||
|
启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。
|
||||||
|
|
||||||
|
## 图文件模板
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "my_device_1",
|
||||||
|
"name": "设备名称",
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"type": "device",
|
||||||
|
"class": "my_device",
|
||||||
|
"position": {"x": 0, "y": 0, "z": 0},
|
||||||
|
"config": {},
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 现有设备接口快照(对齐用)
|
||||||
|
|
||||||
|
对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本:
|
||||||
|
https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/
|
||||||
|
|
||||||
|
### pump_and_valve — 注射泵
|
||||||
|
|
||||||
|
已有设备:syringe_pump_with_valve.runze.SY03B-T06
|
||||||
|
|
||||||
|
属性:status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String)
|
||||||
|
|
||||||
|
方法签名(参数名不可改):
|
||||||
|
- initialize()
|
||||||
|
- set_valve_position(position)
|
||||||
|
- set_position(position: float, max_velocity: float = None)
|
||||||
|
- pull_plunger(volume: float)
|
||||||
|
- push_plunger(volume: float)
|
||||||
|
- set_max_velocity(velocity: float)
|
||||||
|
- set_velocity_grade(velocity)
|
||||||
|
- stop_operation()
|
||||||
|
|
||||||
|
### pump_and_valve — 电磁阀
|
||||||
|
|
||||||
|
属性:status(str), valve_position(str)
|
||||||
|
方法:open(), close(), set_valve_position(position), is_open(), is_closed()
|
||||||
|
|
||||||
|
### temperature
|
||||||
|
|
||||||
|
属性:status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C)
|
||||||
|
|
||||||
|
### motor
|
||||||
|
|
||||||
|
属性:status(str), position(int)
|
||||||
|
|
||||||
|
### sensor
|
||||||
|
|
||||||
|
属性:level(bool), rssi(int)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 结束复制 ↑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `{{KNOWLEDGE_LOADING}}` 变量替换
|
||||||
|
|
||||||
|
根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容:
|
||||||
|
|
||||||
|
### 方案 A:有知识库(Custom GPT / Claude Project)
|
||||||
|
|
||||||
|
```
|
||||||
|
你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。
|
||||||
|
执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
|
||||||
|
本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案 B:有联网能力
|
||||||
|
|
||||||
|
```
|
||||||
|
执行工作流前,从以下 URL 获取完整的设备接入指南:
|
||||||
|
https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md
|
||||||
|
|
||||||
|
该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
|
||||||
|
如果无法访问 URL,使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案 C:无知识库、无联网
|
||||||
|
|
||||||
|
```
|
||||||
|
完整的设备接入指南需要用户在对话中提供。
|
||||||
|
如果用户未主动提供,请在阶段 1 开始前询问:
|
||||||
|
"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。"
|
||||||
|
|
||||||
|
本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。
|
||||||
|
但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 各平台配置指南
|
||||||
|
|
||||||
|
### OpenAI Custom GPT
|
||||||
|
|
||||||
|
1. 进入 https://chat.openai.com/gpts/editor
|
||||||
|
2. **Name**:Uni-Lab-OS 设备接入助手
|
||||||
|
3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。
|
||||||
|
4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
|
||||||
|
5. **Knowledge**:上传 `docs/ai_guides/add_device.md`
|
||||||
|
6. **Capabilities**:开启 Code Interpreter(用于代码验证)
|
||||||
|
7. **Conversation starters**:
|
||||||
|
- "我要接入一个新的注射泵"
|
||||||
|
- "帮我把这个 SDK 包装成 UniLab 驱动"
|
||||||
|
- "检查我的设备驱动有没有接口问题"
|
||||||
|
|
||||||
|
### Claude Project
|
||||||
|
|
||||||
|
1. 创建新 Project
|
||||||
|
2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
|
||||||
|
3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md`
|
||||||
|
|
||||||
|
### API Agent(LangChain / AutoGen / 自建框架)
|
||||||
|
|
||||||
|
```python
|
||||||
|
system_prompt = """
|
||||||
|
<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 如果框架支持工具调用,可注册以下工具:
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"name": "fetch_device_guide",
|
||||||
|
"description": "获取最新的 Uni-Lab-OS 设备接入指南",
|
||||||
|
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fetch_registry",
|
||||||
|
"description": "获取最新的设备注册表",
|
||||||
|
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor Agent Mode
|
||||||
|
|
||||||
|
无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力(Grep 搜索注册表、AskQuestion 收集信息等)。
|
||||||
|
|
||||||
|
### 纯网页对话(ChatGPT / Claude 无 Project)
|
||||||
|
|
||||||
|
1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C)
|
||||||
|
2. 第二条消息上传或粘贴 `add_device.md`
|
||||||
|
3. 第三条消息开始描述设备
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 维护说明
|
||||||
|
|
||||||
|
- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分
|
||||||
|
- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分
|
||||||
|
- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分
|
||||||
|
- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护
|
||||||
@@ -18,13 +18,15 @@ Uni-Lab 开发团队在仓库中提供了 3 个样例:
|
|||||||
|
|
||||||
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||||
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||||
- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`。详细框架说明请参考 {doc}`plc_framework`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 其他工业通信协议:CANopen, Ethernet, OPCUA...
|
## 其他工业通信协议:CANopen, Ethernet, OPCUA...
|
||||||
|
|
||||||
【敬请期待】
|
Uni-Lab 已实现基于 OPC UA 协议的 PLC 接管框架,用于后处理工站等项目。与 Modbus 框架相比,OPC UA 框架额外提供了自动节点发现、订阅推送、断线重连等特性。详细说明请参考 {doc}`plc_framework`。
|
||||||
|
|
||||||
|
其他协议(CANopen、EtherCAT 等)【敬请期待】
|
||||||
|
|
||||||
## 没有接口的老设备老软件:使用 PyWinAuto
|
## 没有接口的老设备老软件:使用 PyWinAuto
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
|
|||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────┐
|
||||||
│ Cloud Platform/Self-hosted Platform │
|
│ Cloud Platform/Self-hosted Platform │
|
||||||
│ uni-lab.bohrium.com │
|
│ leap-lab.bohrium.com │
|
||||||
│ (Resource Management, Task Scheduling, │
|
│ (Resource Management, Task Scheduling, │
|
||||||
│ Monitoring) │
|
│ Monitoring) │
|
||||||
└────────────────────┬─────────────────────────┘
|
└────────────────────┬─────────────────────────┘
|
||||||
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 测试云端连接
|
# 测试云端连接
|
||||||
curl https://uni-lab.bohrium.com/api/v1/health
|
curl https://leap-lab.bohrium.com/api/v1/health
|
||||||
|
|
||||||
# 测试WebSocket
|
# 测试WebSocket
|
||||||
# 启动Uni-Lab后查看日志
|
# 启动Uni-Lab后查看日志
|
||||||
|
|||||||
281
docs/developer_guide/plc_framework.md
Normal file
281
docs/developer_guide/plc_framework.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# PLC 设备接管框架
|
||||||
|
|
||||||
|
> 本文档面向初次接触 UniLab-OS 的开发者,介绍系统如何通过工业协议"接管"(连接并控制)PLC 设备。
|
||||||
|
|
||||||
|
## 什么是"PLC 接管"?
|
||||||
|
|
||||||
|
**PLC**(可编程逻辑控制器)是工厂设备的控制核心,驱动机械臂、泵、阀门等硬件。UniLab-OS 通过网络协议直接读写 PLC 内部变量,从而控制设备运行:
|
||||||
|
|
||||||
|
```
|
||||||
|
UniLab-OS(Python) ←通信协议→ PLC ←电信号→ 电机/气缸/传感器
|
||||||
|
```
|
||||||
|
|
||||||
|
UniLab-OS 提供两套接管框架,对应两种工业协议:
|
||||||
|
|
||||||
|
| 框架 | 协议 | 应用项目 | 核心文件 |
|
||||||
|
| --------------- | ---------------- | ------------------ | ----------------------------------------------------------- |
|
||||||
|
| **Modbus 框架** | Modbus TCP / RTU | 扣式电池装配工站 | `unilabos/device_comms/modbus_plc/client.py` |
|
||||||
|
| **OPC UA 框架** | OPC UA | 后处理工站(怀柔) | `unilabos/devices/workstation/post_process/post_process.py` |
|
||||||
|
|
||||||
|
两套框架**设计思想完全一致**,底层通信协议不同。理解一个,另一个基本触类旁通。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 节点(Node)
|
||||||
|
|
||||||
|
节点是 PLC 内部一个具体的变量地址,可以理解为 PLC 的一个输入/输出端口。
|
||||||
|
|
||||||
|
| 属性 | 说明 | 示例 |
|
||||||
|
| ---- | -------------------------------------- | -------------------- |
|
||||||
|
| 名称 | 人类可读标识 | `COIL_SYS_START_CMD` |
|
||||||
|
| 地址 | PLC 内存地址 | `0x0064` |
|
||||||
|
| 类型 | Coil / HoldRegister / InputRegister 等 | `coil` |
|
||||||
|
|
||||||
|
```
|
||||||
|
PLC 内存空间
|
||||||
|
├── Coil 区: True / False ← 控制开关量(启动/停止/复位)
|
||||||
|
├── Hold Reg: 120, 35.5 … ← 存参数值(速度、位置)
|
||||||
|
└── Input Reg: 99.8, 42 … ← 只读传感器数据
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动作生命周期(Action Lifecycle)
|
||||||
|
|
||||||
|
每个设备动作被拆分为 4 个阶段,用 `try/finally` 保证安全性:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
init(...) # 写入参数(速度、位置等)— 可选
|
||||||
|
start(...) # 发触发信号 + 轮询等待完成
|
||||||
|
stop(...) # 复位触发信号(成功时执行)
|
||||||
|
except:
|
||||||
|
is_err = True
|
||||||
|
finally:
|
||||||
|
cleanup(...) # 无论成败都执行,作为安全兜底
|
||||||
|
```
|
||||||
|
|
||||||
|
| 阶段 | 何时执行 | 典型内容 |
|
||||||
|
| --------- | ----------------------- | ------------------------------------ |
|
||||||
|
| `init` | 成功路径(可选) | 写运动速度 = 20.0 |
|
||||||
|
| `start` | 成功路径 | 写启动位 = True,等待完成位 = True |
|
||||||
|
| `stop` | 成功路径 | 写启动位 = False(正常复位) |
|
||||||
|
| `cleanup` | **无论成败**(finally) | 安全兜底复位,防止异常时设备持续运动 |
|
||||||
|
|
||||||
|
> **为什么 `cleanup` 无论成败都执行?**
|
||||||
|
> 若 `start` 阶段因传感器故障抛出异常,`stop` 会被跳过,PLC 触发位仍为 `True`——设备可能持续运动。`cleanup` 放在 `finally` 块中,作为最后的安全保障,确保 PLC 一定被复位到安全状态。实际上大多数动作将 `cleanup` 设为 `null`,由 `stop` 负责正常复位即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modbus 框架
|
||||||
|
|
||||||
|
**核心文件**:`unilabos/device_comms/modbus_plc/client.py`
|
||||||
|
**参考实现**:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||||
|
|
||||||
|
### 连接与节点注册
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient
|
||||||
|
|
||||||
|
# 1. 建立 TCP 连接
|
||||||
|
client = TCPClient(addr="172.16.28.102", port=502)
|
||||||
|
client.client.connect()
|
||||||
|
|
||||||
|
# 2. 从 CSV 加载节点定义
|
||||||
|
nodes = BaseClient.load_csv("coin_cell_assembly_b.csv")
|
||||||
|
|
||||||
|
# 3. 注册节点,之后可按名称访问
|
||||||
|
client.register_node_list(nodes)
|
||||||
|
|
||||||
|
# 访问节点
|
||||||
|
client.use_node('COIL_SYS_START_CMD').write(True)
|
||||||
|
value, err = client.use_node('COIL_SYS_START_STATUS').read(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSV 格式**(`Name` / `DeviceType` / `Address` / `DataType`):
|
||||||
|
|
||||||
|
| Name | DeviceType | Address | DataType |
|
||||||
|
| ------------------ | ------------- | ------- | -------- |
|
||||||
|
| COIL_SYS_START_CMD | coil | 100 | INT16 |
|
||||||
|
| REG_SPEED | hold_register | 200 | FLOAT32 |
|
||||||
|
|
||||||
|
### 三段式接管流程(扣式电池工站)
|
||||||
|
|
||||||
|
PLC 设备通常需要按固定顺序切换模式,以扣式电池工站为例:
|
||||||
|
|
||||||
|
```
|
||||||
|
Python PLC
|
||||||
|
│── 写 HAND_CMD = True ─────────>│ 切换到手动模式
|
||||||
|
│<─ 读 HAND_STATUS = True ────────│ 确认进入手动
|
||||||
|
│── 写 INIT_CMD = True ──────────>│ 执行初始化
|
||||||
|
│<─ 读 INIT_STATUS = True ─────────│ 初始化完成
|
||||||
|
│── 写 HAND_CMD = False ──────────>│ 复位(脉冲信号)
|
||||||
|
│── 写 INIT_CMD = False ──────────>│ 复位
|
||||||
|
│── 写 AUTO_CMD = True ───────────>│ 切换自动模式
|
||||||
|
│<─ 读 AUTO_STATUS = True ─────────│ 自动模式就绪
|
||||||
|
│── 写 AUTO_CMD = False ──────────>│ 复位
|
||||||
|
│── 写 START_CMD = True ──────────>│ 开始运行
|
||||||
|
│<─ 读 START_STATUS = True ────────│ 运行确认
|
||||||
|
│── 写 START_CMD = False ──────────>│ 复位
|
||||||
|
```
|
||||||
|
|
||||||
|
> **脉冲信号模式**:命令写 `True` → 等待 PLC 状态位确认 → 命令写回 `False`,这是大多数 PLC 的标准触发方式,而不是保持高电平。
|
||||||
|
|
||||||
|
### JSON 配置方式
|
||||||
|
|
||||||
|
Modbus 框架支持纯 JSON 配置,无需手写 Python 流程:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"register_node_list_from_csv_path": {"path": "M01.csv"},
|
||||||
|
"create_flow": [
|
||||||
|
{
|
||||||
|
"name": "归位",
|
||||||
|
"action": [{
|
||||||
|
"address_function_to_create": [
|
||||||
|
{"func_name": "pos_tip", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": true},
|
||||||
|
{"func_name": "pos_tip_read", "node_name": "M01_idlepos_coil_r", "mode": "read", "value": 1},
|
||||||
|
{"func_name": "manual_stop", "node_name": "M01_manual_stop_coil_r","mode": "read", "value": 1}
|
||||||
|
],
|
||||||
|
"create_init_function": {"func_name": "idel_init", "node_name": "M01_idlepos_velocity_rw", "mode": "write", "value": 20.0},
|
||||||
|
"create_start_function": {
|
||||||
|
"func_name": "idel_position",
|
||||||
|
"write_functions": ["pos_tip"],
|
||||||
|
"condition_functions": ["pos_tip_read", "manual_stop"],
|
||||||
|
"stop_condition_expression": "pos_tip_read[0] and manual_stop[0]"
|
||||||
|
},
|
||||||
|
"create_stop_function": {"func_name": "idel_stop", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": false},
|
||||||
|
"create_cleanup_function": null
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"execute_flow": ["归位"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client.execute_procedure_from_json(json_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OPC UA 框架
|
||||||
|
|
||||||
|
**核心文件**:`unilabos/devices/workstation/post_process/post_process.py`
|
||||||
|
**参考实现**:`unilabos/devices/workstation/post_process/opcua_huairou.json`
|
||||||
|
|
||||||
|
### 与 Modbus 的主要区别
|
||||||
|
|
||||||
|
| 特性 | Modbus | OPC UA |
|
||||||
|
| ---------- | -------------------- | --------------------------------- |
|
||||||
|
| 节点发现 | 手动填写 CSV 地址 | **自动遍历**服务器节点树 |
|
||||||
|
| 数据获取 | 轮询(主动问) | **订阅推送**(有变化时通知) |
|
||||||
|
| 节点标识 | 数字地址(如 `100`) | 字符串 NodeId(如 `ns=2;s=速度`) |
|
||||||
|
| 断线处理 | 无 | **后台监控线程**自动重连 |
|
||||||
|
| 认证安全 | 无 | 支持用户名/密码 |
|
||||||
|
| 工作流调用 | 手动调用 | **自动注册为实例方法** |
|
||||||
|
|
||||||
|
### 连接与节点发现
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.post_process.post_process import OpcUaClient
|
||||||
|
|
||||||
|
client = OpcUaClient(
|
||||||
|
url="opc.tcp://192.168.1.100:4840",
|
||||||
|
username="admin", # 可选
|
||||||
|
password="123456", # 可选
|
||||||
|
config_path="opcua_huairou.json", # 自动加载工作流配置
|
||||||
|
cache_timeout=5.0, # 节点值缓存 5 秒
|
||||||
|
subscription_interval=500, # 每 500ms 接收推送
|
||||||
|
)
|
||||||
|
|
||||||
|
# 节点自动通过订阅保持最新值,读取直接查本地缓存
|
||||||
|
value = client.get_node_value("grab_complete")
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON 配置方式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"register_node_list_from_csv_path": {"path": "opcua_nodes_huairou.csv"},
|
||||||
|
"create_flow": [
|
||||||
|
{
|
||||||
|
"name": "trigger_grab_action",
|
||||||
|
"description": "触发反应罐及原料罐抓取动作",
|
||||||
|
"parameters": ["reaction_tank_number", "raw_tank_number"],
|
||||||
|
"action": [{
|
||||||
|
"init_function": {
|
||||||
|
"func_name": "init_grab_params",
|
||||||
|
"write_nodes": ["reaction_tank_number", "raw_tank_number"]
|
||||||
|
},
|
||||||
|
"start_function": {
|
||||||
|
"func_name": "start_grab",
|
||||||
|
"write_nodes": {"grab_trigger": true},
|
||||||
|
"condition_nodes": ["grab_complete"],
|
||||||
|
"stop_condition_expression": "grab_complete == True",
|
||||||
|
"timeout_seconds": 999999.0
|
||||||
|
},
|
||||||
|
"stop_function": {
|
||||||
|
"func_name": "stop_grab",
|
||||||
|
"write_nodes": {"grab_trigger": false}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
配置加载后,工作流自动注册为实例方法:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 直接调用,传入参数,框架自动写入对应节点
|
||||||
|
client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新增设备快速上手
|
||||||
|
|
||||||
|
### 使用 Modbus 框架
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从 PLC 工程师处拿到地址表,按格式填写 CSV(Name/DeviceType/Address/DataType)
|
||||||
|
2. 继承 BaseClient,在 __init__ 中连接并注册节点
|
||||||
|
3. 参考 coin_cell_assembly.py 编写三段式接管函数(手动→初始化→自动→启动)
|
||||||
|
4. 或直接编写 JSON 配置,调用 execute_procedure_from_json()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 OPC UA 框架
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 确认设备支持 OPC UA,拿到服务器 URL(格式:opc.tcp://IP:PORT)
|
||||||
|
2. 准备 CSV 节点定义文件(可选,也可让框架自动发现)
|
||||||
|
3. 编写 JSON 配置:定义 parameters、init/start/stop 函数
|
||||||
|
4. 实例化 OpcUaClient,传入 config_path,直接调用自动注册的工作流方法
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q: `node {name} is not registered` 报错?**
|
||||||
|
A: 节点名不在 CSV 或未调用 `register_node_list_from_csv_path()`。
|
||||||
|
|
||||||
|
**Q: 程序卡死在 `while not status(): sleep(1)`?**
|
||||||
|
A: PLC 未返回预期完成信号。检查:PLC 是否在正确运行模式、状态位地址是否正确、PLC 有无报警。
|
||||||
|
|
||||||
|
**Q: OPC UA 连接成功但读不到节点?**
|
||||||
|
A: 检查节点名称是否与服务器显示名一致(区分中英文)。可调用 `_find_nodes()` 打印服务器全部节点。
|
||||||
|
|
||||||
|
**Q: 应该选 Modbus 还是 OPC UA?**
|
||||||
|
A: 取决于设备支持的协议,由 PLC 工程师决定。OPC UA 功能更完整,条件允许优先选择。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- {doc}`add_device` - 将驱动集成进 UniLab-OS 设备节点
|
||||||
|
- {doc}`add_action` - 为设备添加可调度的动作指令
|
||||||
|
- {doc}`add_yaml` - 编写设备注册表 YAML 文件
|
||||||
@@ -17,6 +17,9 @@ developer_guide/http_api.md
|
|||||||
developer_guide/networking_overview.md
|
developer_guide/networking_overview.md
|
||||||
developer_guide/add_device.md
|
developer_guide/add_device.md
|
||||||
developer_guide/add_action.md
|
developer_guide/add_action.md
|
||||||
|
developer_guide/add_old_device.md
|
||||||
|
developer_guide/plc_framework.md
|
||||||
|
developer_guide/add_protocol.md
|
||||||
developer_guide/add_registry.md
|
developer_guide/add_registry.md
|
||||||
developer_guide/add_yaml.md
|
developer_guide/add_yaml.md
|
||||||
developer_guide/action_includes.md
|
developer_guide/action_includes.md
|
||||||
|
|||||||
@@ -33,11 +33,11 @@
|
|||||||
|
|
||||||
**选择合适的安装包:**
|
**选择合适的安装包:**
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含组件 |
|
| 安装包 | 适用场景 | 包含组件 |
|
||||||
|--------|----------|----------|
|
| --------------- | ---------------------------- | --------------------------------------------- |
|
||||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
||||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
||||||
|
|
||||||
**关键步骤:**
|
**关键步骤:**
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
|||||||
```
|
```
|
||||||
|
|
||||||
**选择建议:**
|
**选择建议:**
|
||||||
|
|
||||||
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
||||||
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
||||||
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
||||||
@@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
|
|||||||
|
|
||||||
#### 2.1 注册实验室账号
|
#### 2.1 注册实验室账号
|
||||||
|
|
||||||
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
2. 注册账号并登录
|
2. 注册账号并登录
|
||||||
3. 创建新实验室
|
3. 创建新实验室
|
||||||
|
|
||||||
@@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
|
|
||||||
#### 5.2 访问 Web 界面
|
#### 5.2 访问 Web 界面
|
||||||
|
|
||||||
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
|
|
||||||
#### 5.3 添加设备和物料
|
#### 5.3 添加设备和物料
|
||||||
|
|
||||||
@@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
**示例场景:** 创建一个简单的液体转移实验
|
**示例场景:** 创建一个简单的液体转移实验
|
||||||
|
|
||||||
1. **添加工作站(必需):**
|
1. **添加工作站(必需):**
|
||||||
|
|
||||||
- 在"仪器设备"中找到 `work_station`
|
- 在"仪器设备"中找到 `work_station`
|
||||||
- 添加 `workstation` x1
|
- 添加 `workstation` x1
|
||||||
|
|
||||||
2. **添加虚拟转移泵:**
|
2. **添加虚拟转移泵:**
|
||||||
|
|
||||||
- 在"仪器设备"中找到 `virtual_device`
|
- 在"仪器设备"中找到 `virtual_device`
|
||||||
- 添加 `virtual_transfer_pump` x1
|
- 添加 `virtual_transfer_pump` x1
|
||||||
|
|
||||||
@@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt
|
|||||||
```
|
```
|
||||||
|
|
||||||
**为什么使用这种方式?**
|
**为什么使用这种方式?**
|
||||||
|
|
||||||
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
||||||
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
||||||
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
||||||
@@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
**详细步骤:**
|
**详细步骤:**
|
||||||
|
|
||||||
1. **需求分析**:
|
1. **需求分析**:
|
||||||
|
|
||||||
- 明确实验流程
|
- 明确实验流程
|
||||||
- 列出所需设备和物料
|
- 列出所需设备和物料
|
||||||
- 设计工作流程图
|
- 设计工作流程图
|
||||||
|
|
||||||
2. **环境搭建**:
|
2. **环境搭建**:
|
||||||
|
|
||||||
- 安装 Uni-Lab-OS
|
- 安装 Uni-Lab-OS
|
||||||
- 创建实验室账号
|
- 创建实验室账号
|
||||||
- 准备开发工具(IDE、Git)
|
- 准备开发工具(IDE、Git)
|
||||||
|
|
||||||
3. **原型验证**:
|
3. **原型验证**:
|
||||||
|
|
||||||
- 使用虚拟设备测试流程
|
- 使用虚拟设备测试流程
|
||||||
- 验证工作流逻辑
|
- 验证工作流逻辑
|
||||||
- 调整参数
|
- 调整参数
|
||||||
|
|
||||||
4. **迭代开发**:
|
4. **迭代开发**:
|
||||||
|
|
||||||
- 实现自定义设备驱动(同时撰写单点函数测试)
|
- 实现自定义设备驱动(同时撰写单点函数测试)
|
||||||
- 编写注册表
|
- 编写注册表
|
||||||
- 单元测试
|
- 单元测试
|
||||||
- 集成测试
|
- 集成测试
|
||||||
|
|
||||||
5. **测试部署**:
|
5. **测试部署**:
|
||||||
|
|
||||||
- 连接真实硬件
|
- 连接真实硬件
|
||||||
- 空跑测试
|
- 空跑测试
|
||||||
- 小规模试验
|
- 小规模试验
|
||||||
@@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
#### 14.5 社区支持
|
#### 14.5 社区支持
|
||||||
|
|
||||||
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||||
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
- **官方网站**:[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ unilab
|
|||||||
|
|
||||||
**云端图文件管理**:
|
**云端图文件管理**:
|
||||||
|
|
||||||
1. 登录 https://uni-lab.bohrium.com
|
1. 登录 https://leap-lab.bohrium.com
|
||||||
2. 进入"设备配置"
|
2. 进入"设备配置"
|
||||||
3. 创建或编辑配置
|
3. 创建或编辑配置
|
||||||
4. 保存到云端
|
4. 保存到云端
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
||||||
|
|
||||||
- **工作目录设置**:
|
- **工作目录设置**:
|
||||||
|
|
||||||
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
||||||
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
||||||
- 可通过 `--working_dir` 指定自定义工作目录
|
- 可通过 `--working_dir` 指定自定义工作目录
|
||||||
@@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
|
|
||||||
支持多种后端环境:
|
支持多种后端环境:
|
||||||
|
|
||||||
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
|
- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
|
||||||
- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
|
- `--addr uat`:UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
|
||||||
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
||||||
- 自定义地址:直接指定完整 URL
|
- 自定义地址:直接指定完整 URL
|
||||||
|
|
||||||
@@ -176,7 +175,7 @@ unilab --config path/to/your/config.py
|
|||||||
|
|
||||||
如果是首次使用,系统会:
|
如果是首次使用,系统会:
|
||||||
|
|
||||||
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
|
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
|
||||||
2. 引导创建配置文件
|
2. 引导创建配置文件
|
||||||
3. 设置工作目录
|
3. 设置工作目录
|
||||||
|
|
||||||
@@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
|||||||
|
|
||||||
如果提示 "后续运行必须拥有一个实验室",请确保:
|
如果提示 "后续运行必须拥有一个实验室",请确保:
|
||||||
|
|
||||||
- 已在 https://uni-lab.bohrium.com 注册实验室
|
- 已在 https://leap-lab.bohrium.com 注册实验室
|
||||||
- 正确设置了 `--ak` 和 `--sk` 参数
|
- 正确设置了 `--ak` 和 `--sk` 参数
|
||||||
- 配置文件中包含正确的认证信息
|
- 配置文件中包含正确的认证信息
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.19"
|
version: "0.11.1"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.19',
|
version='0.11.1',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.10.19"
|
__version__ = "0.11.1"
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ from typing import Dict, Any, List
|
|||||||
import networkx as nx
|
import networkx as nx
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符
|
||||||
|
# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃
|
||||||
|
if sys.platform == "win32":
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
# 首先添加项目根目录到路径
|
# 首先添加项目根目录到路径
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||||
@@ -233,7 +242,7 @@ def parse_args():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--addr",
|
"--addr",
|
||||||
type=str,
|
type=str,
|
||||||
default="https://uni-lab.bohrium.com/api/v1",
|
default="https://leap-lab.bohrium.com/api/v1",
|
||||||
help="Laboratory backend address",
|
help="Laboratory backend address",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -438,10 +447,10 @@ def main():
|
|||||||
if args.addr != parser.get_default("addr"):
|
if args.addr != parser.get_default("addr"):
|
||||||
if args.addr == "test":
|
if args.addr == "test":
|
||||||
print_status("使用测试环境地址", "info")
|
print_status("使用测试环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||||
elif args.addr == "uat":
|
elif args.addr == "uat":
|
||||||
print_status("使用uat环境地址", "info")
|
print_status("使用uat环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1"
|
||||||
elif args.addr == "local":
|
elif args.addr == "local":
|
||||||
print_status("使用本地环境地址", "info")
|
print_status("使用本地环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||||
@@ -553,7 +562,7 @@ def main():
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
if not BasicConfig.ak or not BasicConfig.sk:
|
if not BasicConfig.ak or not BasicConfig.sk:
|
||||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
graph: nx.Graph
|
graph: nx.Graph
|
||||||
resource_tree_set: ResourceTreeSet
|
resource_tree_set: ResourceTreeSet
|
||||||
@@ -621,8 +630,6 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||||
# 仅在本地文件模式下有意义:本地文件只含设备结构,远端有已保存的物料,需要 merge
|
|
||||||
# 远端模式下 resource_tree_set 与 request_startup_json 来自同一份数据,merge 为空操作
|
|
||||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||||
print_status("开始同步远端物料到本地...", "info")
|
print_status("开始同步远端物料到本地...", "info")
|
||||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class HTTPClient:
|
|||||||
auth_secret = BasicConfig.auth_secret()
|
auth_secret = BasicConfig.auth_secret()
|
||||||
self.auth = auth_secret
|
self.auth = auth_secret
|
||||||
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
||||||
|
# 复用 TCP/TLS 连接,避免每次请求重新握手
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
|
||||||
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
||||||
|
|
||||||
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
||||||
@@ -48,7 +51,7 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material/edge",
|
f"{self.remote_addr}/edge/material/edge",
|
||||||
json={
|
json={
|
||||||
"edges": resources,
|
"edges": resources,
|
||||||
@@ -75,26 +78,28 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
# dump() 只调用一次,复用给文件保存和 HTTP 请求
|
||||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
|
||||||
f.write(json.dumps(payload, indent=4))
|
|
||||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
|
||||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
|
||||||
nodes_info = [x for xs in resources.dump() for x in xs]
|
nodes_info = [x for xs in resources.dump() for x in xs]
|
||||||
|
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||||
|
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
|
||||||
|
body_bytes = _fast_dumps(payload)
|
||||||
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
|
||||||
|
f.write(_fast_dumps_pretty(payload))
|
||||||
|
http_headers = {"Content-Type": "application/json"}
|
||||||
if not self.initialized or first_add:
|
if not self.initialized or first_add:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material",
|
f"{self.remote_addr}/edge/material",
|
||||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
data=body_bytes,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers=http_headers,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/edge/material",
|
f"{self.remote_addr}/edge/material",
|
||||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
data=body_bytes,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers=http_headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ class HTTPClient:
|
|||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material/query",
|
f"{self.remote_addr}/edge/material/query",
|
||||||
json={"uuids": uuid_list, "with_children": with_children},
|
json={"uuids": uuid_list, "with_children": with_children},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -147,6 +152,7 @@ class HTTPClient:
|
|||||||
logger.error(f"查询物料失败: {response.text}")
|
logger.error(f"查询物料失败: {response.text}")
|
||||||
else:
|
else:
|
||||||
data = res["data"]["nodes"]
|
data = res["data"]["nodes"]
|
||||||
|
logger.trace(f"resource_tree_get查询到物料: {data}")
|
||||||
return data
|
return data
|
||||||
else:
|
else:
|
||||||
logger.error(f"查询物料失败: {response.text}")
|
logger.error(f"查询物料失败: {response.text}")
|
||||||
@@ -164,14 +170,14 @@ class HTTPClient:
|
|||||||
if not self.initialized:
|
if not self.initialized:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=100,
|
timeout=100,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -198,7 +204,7 @@ class HTTPClient:
|
|||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
||||||
response = requests.get(
|
response = self._session.get(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
params={"id": id, "with_children": with_children},
|
params={"id": id, "with_children": with_children},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -239,14 +245,14 @@ class HTTPClient:
|
|||||||
if not self.initialized:
|
if not self.initialized:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=100,
|
timeout=100,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -276,7 +282,7 @@ class HTTPClient:
|
|||||||
with open(file_path, "rb") as file:
|
with open(file_path, "rb") as file:
|
||||||
files = {"files": file}
|
files = {"files": file}
|
||||||
logger.info(f"上传文件: {file_path} 到 {scene}")
|
logger.info(f"上传文件: {file_path} 到 {scene}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
||||||
files=files,
|
files=files,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -316,7 +322,7 @@ class HTTPClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Content-Encoding": "gzip",
|
"Content-Encoding": "gzip",
|
||||||
}
|
}
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/resource",
|
f"{self.remote_addr}/lab/resource",
|
||||||
data=compressed_body,
|
data=compressed_body,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -350,7 +356,7 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
response = requests.get(
|
response = self._session.get(
|
||||||
f"{self.remote_addr}/edge/material/download",
|
f"{self.remote_addr}/edge/material/download",
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=(3, 30),
|
timeout=(3, 30),
|
||||||
@@ -411,7 +417,7 @@ class HTTPClient:
|
|||||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
||||||
|
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
|
|||||||
@@ -1269,7 +1269,13 @@ class QueueProcessor:
|
|||||||
if not queued_jobs:
|
if not queued_jobs:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
queue_summary = {}
|
||||||
|
for j in queued_jobs:
|
||||||
|
key = f"{j.device_id}/{j.action_name}"
|
||||||
|
queue_summary[key] = queue_summary.get(key, 0) + 1
|
||||||
|
logger.debug(
|
||||||
|
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
|
||||||
|
)
|
||||||
|
|
||||||
for job_info in queued_jobs:
|
for job_info in queued_jobs:
|
||||||
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class WSConfig:
|
|||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
remote_addr = "https://leap-lab.bohrium.com/api/v1"
|
||||||
|
|
||||||
|
|
||||||
# ROS配置
|
# ROS配置
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
LaiYu液体处理设备后端模块
|
LaiYu液体处理设备后端模块
|
||||||
|
|
||||||
提供设备后端接口和实现
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend
|
||||||
|
|
||||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
__all__ = ['UniLiquidHandlerLaiyuBackend']
|
||||||
|
|||||||
@@ -1,334 +0,0 @@
|
|||||||
"""
|
|
||||||
LaiYu液体处理设备后端实现
|
|
||||||
|
|
||||||
提供设备的后端接口和控制逻辑
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
# 尝试导入PyLabRobot后端
|
|
||||||
try:
|
|
||||||
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
|
|
||||||
PYLABROBOT_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
PYLABROBOT_AVAILABLE = False
|
|
||||||
# 创建模拟后端基类
|
|
||||||
class LiquidHandlerBackend:
|
|
||||||
def __init__(self, name: str):
|
|
||||||
self.name = name
|
|
||||||
self.is_connected = False
|
|
||||||
|
|
||||||
def connect(self):
|
|
||||||
"""连接设备"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def disconnect(self):
|
|
||||||
"""断开连接"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class LaiYuLiquidBackend(LiquidHandlerBackend):
|
|
||||||
"""LaiYu液体处理设备后端"""
|
|
||||||
|
|
||||||
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
|
|
||||||
"""
|
|
||||||
初始化LaiYu液体处理设备后端
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 后端名称
|
|
||||||
"""
|
|
||||||
if PYLABROBOT_AVAILABLE:
|
|
||||||
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
|
|
||||||
super().__init__()
|
|
||||||
else:
|
|
||||||
# 模拟版本接受 name 参数
|
|
||||||
super().__init__(name)
|
|
||||||
|
|
||||||
self.name = name
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self.is_connected = False
|
|
||||||
self.device_info = {
|
|
||||||
"name": "LaiYu液体处理设备",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"manufacturer": "LaiYu",
|
|
||||||
"model": "LaiYu_Liquid_Handler"
|
|
||||||
}
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
|
||||||
"""
|
|
||||||
连接到LaiYu液体处理设备
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 连接是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.logger.info("正在连接到LaiYu液体处理设备...")
|
|
||||||
# 这里应该实现实际的设备连接逻辑
|
|
||||||
# 目前返回模拟连接成功
|
|
||||||
self.is_connected = True
|
|
||||||
self.logger.info("成功连接到LaiYu液体处理设备")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
|
|
||||||
self.is_connected = False
|
|
||||||
return False
|
|
||||||
|
|
||||||
def disconnect(self) -> bool:
|
|
||||||
"""
|
|
||||||
断开与LaiYu液体处理设备的连接
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 断开连接是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
|
|
||||||
# 这里应该实现实际的设备断开连接逻辑
|
|
||||||
self.is_connected = False
|
|
||||||
self.logger.info("成功断开与LaiYu液体处理设备的连接")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_device_connected(self) -> bool:
|
|
||||||
"""
|
|
||||||
检查设备是否已连接
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 设备是否已连接
|
|
||||||
"""
|
|
||||||
return self.is_connected
|
|
||||||
|
|
||||||
def get_device_info(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
获取设备信息
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict[str, Any]: 设备信息字典
|
|
||||||
"""
|
|
||||||
return self.device_info.copy()
|
|
||||||
|
|
||||||
def home_device(self) -> bool:
|
|
||||||
"""
|
|
||||||
设备归零操作
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 归零是否成功
|
|
||||||
"""
|
|
||||||
if not self.is_connected:
|
|
||||||
self.logger.error("设备未连接,无法执行归零操作")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.logger.info("正在执行设备归零操作...")
|
|
||||||
# 这里应该实现实际的设备归零逻辑
|
|
||||||
self.logger.info("设备归零操作完成")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"设备归零操作失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
|
|
||||||
"""
|
|
||||||
吸液操作
|
|
||||||
|
|
||||||
Args:
|
|
||||||
volume: 吸液体积 (微升)
|
|
||||||
location: 吸液位置信息
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 吸液是否成功
|
|
||||||
"""
|
|
||||||
if not self.is_connected:
|
|
||||||
self.logger.error("设备未连接,无法执行吸液操作")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
|
|
||||||
# 这里应该实现实际的吸液逻辑
|
|
||||||
self.logger.info("吸液操作完成")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"吸液操作失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
|
|
||||||
"""
|
|
||||||
排液操作
|
|
||||||
|
|
||||||
Args:
|
|
||||||
volume: 排液体积 (微升)
|
|
||||||
location: 排液位置信息
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 排液是否成功
|
|
||||||
"""
|
|
||||||
if not self.is_connected:
|
|
||||||
self.logger.error("设备未连接,无法执行排液操作")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
|
|
||||||
# 这里应该实现实际的排液逻辑
|
|
||||||
self.logger.info("排液操作完成")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"排液操作失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
|
|
||||||
"""
|
|
||||||
取枪头操作
|
|
||||||
|
|
||||||
Args:
|
|
||||||
location: 枪头位置信息
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 取枪头是否成功
|
|
||||||
"""
|
|
||||||
if not self.is_connected:
|
|
||||||
self.logger.error("设备未连接,无法执行取枪头操作")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.logger.info(f"正在执行取枪头操作: 位置={location}")
|
|
||||||
# 这里应该实现实际的取枪头逻辑
|
|
||||||
self.logger.info("取枪头操作完成")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"取枪头操作失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def drop_tip(self, location: Dict[str, Any]) -> bool:
|
|
||||||
"""
|
|
||||||
丢弃枪头操作
|
|
||||||
|
|
||||||
Args:
|
|
||||||
location: 丢弃位置信息
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 丢弃枪头是否成功
|
|
||||||
"""
|
|
||||||
if not self.is_connected:
|
|
||||||
self.logger.error("设备未连接,无法执行丢弃枪头操作")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
|
|
||||||
# 这里应该实现实际的丢弃枪头逻辑
|
|
||||||
self.logger.info("丢弃枪头操作完成")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"丢弃枪头操作失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def move_to(self, location: Dict[str, Any]) -> bool:
|
|
||||||
"""
|
|
||||||
移动到指定位置
|
|
||||||
|
|
||||||
Args:
|
|
||||||
location: 目标位置信息
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 移动是否成功
|
|
||||||
"""
|
|
||||||
if not self.is_connected:
|
|
||||||
self.logger.error("设备未连接,无法执行移动操作")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.logger.info(f"正在移动到位置: {location}")
|
|
||||||
# 这里应该实现实际的移动逻辑
|
|
||||||
self.logger.info("移动操作完成")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"移动操作失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
获取设备状态
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict[str, Any]: 设备状态信息
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"connected": self.is_connected,
|
|
||||||
"device_info": self.device_info,
|
|
||||||
"status": "ready" if self.is_connected else "disconnected"
|
|
||||||
}
|
|
||||||
|
|
||||||
# PyLabRobot 抽象方法实现
|
|
||||||
def stop(self):
|
|
||||||
"""停止所有操作"""
|
|
||||||
self.logger.info("停止所有操作")
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def num_channels(self) -> int:
|
|
||||||
"""返回通道数量"""
|
|
||||||
return 1 # 单通道移液器
|
|
||||||
|
|
||||||
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
|
|
||||||
"""检查是否可以拾取吸头"""
|
|
||||||
return True # 简化实现,总是返回True
|
|
||||||
|
|
||||||
def pick_up_tips(self, tip_rack, tip_positions):
|
|
||||||
"""拾取多个吸头"""
|
|
||||||
self.logger.info(f"拾取吸头: {tip_positions}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def drop_tips(self, tip_rack, tip_positions):
|
|
||||||
"""丢弃多个吸头"""
|
|
||||||
self.logger.info(f"丢弃吸头: {tip_positions}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def pick_up_tips96(self, tip_rack):
|
|
||||||
"""拾取96个吸头"""
|
|
||||||
self.logger.info("拾取96个吸头")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def drop_tips96(self, tip_rack):
|
|
||||||
"""丢弃96个吸头"""
|
|
||||||
self.logger.info("丢弃96个吸头")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def aspirate96(self, volume, plate, well_positions):
|
|
||||||
"""96通道吸液"""
|
|
||||||
self.logger.info(f"96通道吸液: 体积={volume}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def dispense96(self, volume, plate, well_positions):
|
|
||||||
"""96通道排液"""
|
|
||||||
self.logger.info(f"96通道排液: 体积={volume}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def pick_up_resource(self, resource, location):
|
|
||||||
"""拾取资源"""
|
|
||||||
self.logger.info(f"拾取资源: {resource}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def drop_resource(self, resource, location):
|
|
||||||
"""放置资源"""
|
|
||||||
self.logger.info(f"放置资源: {resource}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def move_picked_up_resource(self, resource, location):
|
|
||||||
"""移动已拾取的资源"""
|
|
||||||
self.logger.info(f"移动资源: {resource} 到 {location}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
|
|
||||||
"""
|
|
||||||
创建LaiYu液体处理设备后端实例
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 后端名称
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LaiYuLiquidBackend: 后端实例
|
|
||||||
"""
|
|
||||||
return LaiYuLiquidBackend(name)
|
|
||||||
@@ -1,385 +1,307 @@
|
|||||||
|
"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式
|
||||||
import json
|
|
||||||
|
硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致:
|
||||||
|
1. XYZController(auto_connect=True) — 先开串口
|
||||||
|
2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁
|
||||||
|
3. home_all_axes() + pipette.initialize()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from pylabrobot.liquid_handling.backends.backend import (
|
from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend
|
||||||
LiquidHandlerBackend,
|
|
||||||
)
|
|
||||||
from pylabrobot.liquid_handling.standard import (
|
from pylabrobot.liquid_handling.standard import (
|
||||||
Drop,
|
Drop,
|
||||||
DropTipRack,
|
DropTipRack,
|
||||||
MultiHeadAspirationContainer,
|
MultiHeadAspirationContainer,
|
||||||
MultiHeadAspirationPlate,
|
MultiHeadAspirationPlate,
|
||||||
MultiHeadDispenseContainer,
|
MultiHeadDispenseContainer,
|
||||||
MultiHeadDispensePlate,
|
MultiHeadDispensePlate,
|
||||||
Pickup,
|
Pickup,
|
||||||
PickupTipRack,
|
PickupTipRack,
|
||||||
ResourceDrop,
|
ResourceDrop,
|
||||||
ResourceMove,
|
ResourceMove,
|
||||||
ResourcePickup,
|
ResourcePickup,
|
||||||
SingleChannelAspiration,
|
SingleChannelAspiration,
|
||||||
SingleChannelDispense,
|
SingleChannelDispense,
|
||||||
)
|
)
|
||||||
from pylabrobot.resources import Resource, Tip
|
from pylabrobot.resources import Resource, Tip
|
||||||
|
|
||||||
import rclpy
|
from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController
|
||||||
from rclpy.node import Node
|
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import (
|
||||||
from sensor_msgs.msg import JointState
|
PipetteController,
|
||||||
import time
|
TipStatus,
|
||||||
from rclpy.action import ActionClient
|
)
|
||||||
from unilabos_msgs.action import SendCmd
|
|
||||||
import re
|
|
||||||
|
|
||||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
|
logger = logging.getLogger(__name__)
|
||||||
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
|
|
||||||
|
|
||||||
|
|
||||||
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||||
"""Chatter box backend for device-free testing. Prints out all operations."""
|
"""LaiYu 硬件后端 — PLR Backend 接口实现"""
|
||||||
|
|
||||||
_pip_length = 5
|
def __init__(
|
||||||
_vol_length = 8
|
self,
|
||||||
_resource_length = 20
|
num_channels: int = 1,
|
||||||
_offset_length = 16
|
tip_length: float = 0,
|
||||||
_flow_rate_length = 10
|
total_height: float = 310,
|
||||||
_blowout_length = 10
|
port: str = "/dev/ttyUSB0",
|
||||||
_lld_z_length = 10
|
baudrate: int = 115200,
|
||||||
_kwargs_length = 15
|
pipette_address: int = 4,
|
||||||
_tip_type_length = 12
|
):
|
||||||
_max_volume_length = 16
|
super().__init__()
|
||||||
_fitting_depth_length = 20
|
self._num_channels = num_channels
|
||||||
_tip_length_length = 16
|
self.tip_length = tip_length
|
||||||
# _pickup_method_length = 20
|
self.total_height = total_height
|
||||||
_filter_length = 10
|
|
||||||
|
|
||||||
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
|
# 保存配置,延迟到 setup() 再创建硬件对象
|
||||||
"""Initialize a chatter box backend."""
|
self._port = port
|
||||||
super().__init__()
|
self._baudrate = baudrate
|
||||||
self._num_channels = num_channels
|
self._pipette_address = pipette_address
|
||||||
self.tip_length = tip_length
|
|
||||||
self.total_height = total_height
|
|
||||||
# rclpy.init()
|
|
||||||
if not rclpy.ok():
|
|
||||||
rclpy.init()
|
|
||||||
self.joint_state_publisher = None
|
|
||||||
self.hardware_interface = PipetteController(port=port)
|
|
||||||
|
|
||||||
async def setup(self):
|
self._xyz: Optional[XYZController] = None
|
||||||
# self.joint_state_publisher = JointStatePublisher()
|
self._pipette_ctrl: Optional[PipetteController] = None
|
||||||
# self.hardware_interface.xyz_controller.connect_device()
|
self._ros_node = None
|
||||||
# self.hardware_interface.xyz_controller.home_all_axes()
|
|
||||||
await super().setup()
|
|
||||||
self.hardware_interface.connect()
|
|
||||||
self.hardware_interface.initialize()
|
|
||||||
|
|
||||||
print("Setting up the liquid handler.")
|
# ------------------------------------------------------------------ lifecycle
|
||||||
|
|
||||||
async def stop(self):
|
def post_init(self, ros_node):
|
||||||
print("Stopping the liquid handler.")
|
"""接收 ROS 节点引用(由 Handler.post_init 调用)"""
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
async def setup(self):
|
||||||
return {**super().serialize(), "num_channels": self.num_channels}
|
"""按路径 B 顺序初始化硬件"""
|
||||||
|
await super().setup()
|
||||||
|
|
||||||
def pipette_aspirate(self, volume: float, flow_rate: float):
|
# 1. XYZ 先开串口
|
||||||
|
self._xyz = XYZController(
|
||||||
|
port=self._port,
|
||||||
|
baudrate=self._baudrate,
|
||||||
|
auto_connect=True,
|
||||||
|
)
|
||||||
|
if not self._xyz.is_connected:
|
||||||
|
raise RuntimeError("XYZ 控制器连接失败")
|
||||||
|
|
||||||
self.hardware_interface.pipette.set_max_speed(flow_rate)
|
# 2. PipetteController 共享 XYZ 串口
|
||||||
res = self.hardware_interface.pipette.aspirate(volume=volume)
|
self._pipette_ctrl = PipetteController(
|
||||||
|
port=self._port,
|
||||||
if not res:
|
address=self._pipette_address,
|
||||||
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
|
)
|
||||||
return
|
self._pipette_ctrl.connect_shared(
|
||||||
|
serial_conn=self._xyz.serial_conn,
|
||||||
|
serial_lock=self._xyz.serial_lock,
|
||||||
|
xyz_controller=self._xyz,
|
||||||
|
)
|
||||||
|
|
||||||
self.hardware_interface.current_volume += volume
|
# 3. 回零 + 移液器初始化
|
||||||
|
self._xyz.home_all_axes()
|
||||||
|
self._pipette_ctrl.initialize()
|
||||||
|
|
||||||
def pipette_dispense(self, volume: float, flow_rate: float):
|
logger.info("LaiYu 后端硬件初始化完成")
|
||||||
|
|
||||||
self.hardware_interface.pipette.set_max_speed(flow_rate)
|
async def stop(self):
|
||||||
res = self.hardware_interface.pipette.dispense(volume=volume)
|
"""正确断开硬件"""
|
||||||
if not res:
|
try:
|
||||||
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
|
if self._pipette_ctrl:
|
||||||
return
|
self._pipette_ctrl.disconnect_shared()
|
||||||
self.hardware_interface.current_volume -= volume
|
if self._xyz:
|
||||||
|
self._xyz.disconnect()
|
||||||
|
logger.info("LaiYu 后端硬件已断开")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"停止后端失败: {e}")
|
||||||
|
|
||||||
@property
|
# ------------------------------------------------------------------ helpers
|
||||||
def num_channels(self) -> int:
|
|
||||||
return self._num_channels
|
|
||||||
|
|
||||||
async def assigned_resource_callback(self, resource: Resource):
|
def _plr_to_machine_coords(self, resource, offset):
|
||||||
print(f"Resource {resource.name} was assigned to the liquid handler.")
|
"""PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)"""
|
||||||
|
coordinate = resource.get_absolute_location(x="c", y="c")
|
||||||
|
x = coordinate.x + offset.x
|
||||||
|
y = coordinate.y + offset.y
|
||||||
|
z_plr = coordinate.z + offset.z
|
||||||
|
return x, -y, self.total_height - (z_plr + self.tip_length)
|
||||||
|
|
||||||
async def unassigned_resource_callback(self, name: str):
|
def _pipette_aspirate(self, volume: float, flow_rate: float):
|
||||||
print(f"Resource {name} was unassigned from the liquid handler.")
|
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
|
||||||
|
res = self._pipette_ctrl.pipette.aspirate(volume=volume)
|
||||||
|
if not res:
|
||||||
|
logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}")
|
||||||
|
return
|
||||||
|
self._pipette_ctrl.current_volume += volume
|
||||||
|
|
||||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
def _pipette_dispense(self, volume: float, flow_rate: float):
|
||||||
print("Picking up tips:")
|
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
|
||||||
# print(ops.tip)
|
res = self._pipette_ctrl.pipette.dispense(volume=volume)
|
||||||
header = (
|
if not res:
|
||||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}")
|
||||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
return
|
||||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
self._pipette_ctrl.current_volume -= volume
|
||||||
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
|
||||||
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
|
||||||
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
|
||||||
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
|
||||||
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
|
||||||
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
|
||||||
)
|
|
||||||
# print(header)
|
|
||||||
|
|
||||||
for op, channel in zip(ops, use_channels):
|
# ------------------------------------------------------------------ properties
|
||||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
|
||||||
row = (
|
|
||||||
f" p{channel}: "
|
|
||||||
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
|
||||||
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
|
||||||
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
|
||||||
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
|
||||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
|
||||||
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
|
||||||
)
|
|
||||||
# print(row)
|
|
||||||
# print(op.resource.get_absolute_location())
|
|
||||||
|
|
||||||
self.tip_length = ops[0].tip.total_tip_length
|
|
||||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
|
||||||
offset_xyz = ops[0].offset
|
|
||||||
x = coordinate.x + offset_xyz.x
|
|
||||||
y = coordinate.y + offset_xyz.y
|
|
||||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
|
||||||
# print("moving")
|
|
||||||
self.hardware_interface._update_tip_status()
|
|
||||||
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
|
||||||
print("已有枪头,无需重复拾取")
|
|
||||||
return
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
|
|
||||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
|
|
||||||
# goback()
|
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
return {**super().serialize(), "num_channels": self.num_channels}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_channels(self) -> int:
|
||||||
|
return self._num_channels
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ resource callbacks
|
||||||
|
|
||||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
async def assigned_resource_callback(self, resource: Resource):
|
||||||
print("Dropping tips:")
|
logger.info(f"Resource {resource.name} was assigned to the liquid handler.")
|
||||||
header = (
|
|
||||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
|
||||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
|
||||||
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
|
||||||
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
|
||||||
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
|
||||||
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
|
||||||
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
|
||||||
)
|
|
||||||
# print(header)
|
|
||||||
|
|
||||||
for op, channel in zip(ops, use_channels):
|
async def unassigned_resource_callback(self, name: str):
|
||||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
logger.info(f"Resource {name} was unassigned from the liquid handler.")
|
||||||
row = (
|
|
||||||
f" p{channel}: "
|
|
||||||
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
|
||||||
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
|
||||||
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
|
||||||
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
|
||||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
|
||||||
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
|
||||||
)
|
|
||||||
# print(row)
|
|
||||||
|
|
||||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
# ------------------------------------------------------------------ pick_up_tips
|
||||||
offset_xyz = ops[0].offset
|
|
||||||
x = coordinate.x + offset_xyz.x
|
|
||||||
y = coordinate.y + offset_xyz.y
|
|
||||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
|
|
||||||
# print(x, y, z)
|
|
||||||
# print("moving")
|
|
||||||
self.hardware_interface._update_tip_status()
|
|
||||||
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
|
|
||||||
print("无枪头,无需丢弃")
|
|
||||||
return
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
|
||||||
self.hardware_interface.eject_tip
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
|
||||||
|
|
||||||
async def aspirate(
|
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||||
self,
|
tip = ops[0].tip
|
||||||
ops: List[SingleChannelAspiration],
|
self.tip_length = tip.total_tip_length
|
||||||
use_channels: List[int],
|
x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||||
**backend_kwargs,
|
|
||||||
):
|
|
||||||
print("Aspirating:")
|
|
||||||
header = (
|
|
||||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
|
||||||
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
|
||||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
|
||||||
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
|
||||||
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
|
||||||
# f"{'liquids':<20}" # TODO: add liquids
|
|
||||||
)
|
|
||||||
for key in backend_kwargs:
|
|
||||||
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
|
|
||||||
# print(header)
|
|
||||||
|
|
||||||
for o, p in zip(ops, use_channels):
|
self._pipette_ctrl._update_tip_status()
|
||||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED:
|
||||||
row = (
|
logger.warning("已有枪头,无需重复拾取")
|
||||||
f" p{p}: "
|
return
|
||||||
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
|
||||||
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
|
||||||
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
|
||||||
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
|
||||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
|
||||||
)
|
|
||||||
for key, value in backend_kwargs.items():
|
|
||||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
|
||||||
value = "".join("T" if v else "F" for v in value)
|
|
||||||
if isinstance(value, list):
|
|
||||||
value = "".join(map(str, value))
|
|
||||||
row += f" {value:<15}"
|
|
||||||
# print(row)
|
|
||||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
|
||||||
offset_xyz = ops[0].offset
|
|
||||||
x = coordinate.x + offset_xyz.x
|
|
||||||
y = coordinate.y + offset_xyz.y
|
|
||||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
|
||||||
# print(x, y, z)
|
|
||||||
# print("moving")
|
|
||||||
|
|
||||||
# 判断枪头是否存在
|
try:
|
||||||
self.hardware_interface._update_tip_status()
|
# 1. 移到枪头正上方
|
||||||
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200)
|
||||||
print("无枪头,无法吸液")
|
# 2. 下压到套枪头深度(fitting_depth 是枪头套入长度)
|
||||||
return
|
z_pickup = z_top + tip.fitting_depth
|
||||||
# 判断吸液量是否超过枪头容量
|
self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100)
|
||||||
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
|
# 3. 退回安全高度
|
||||||
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
|
self._xyz.move_to_work_coord_safe(
|
||||||
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
|
z=self._xyz.machine_config.safe_z_height, speed=100
|
||||||
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
|
)
|
||||||
return
|
except Exception as e:
|
||||||
|
logger.error(f"pick_up_tips 移动失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# 移动到吸液位置
|
# ------------------------------------------------------------------ drop_tips
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
|
||||||
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
|
||||||
|
|
||||||
|
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||||
|
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||||
|
z -= 20 # 额外下移补偿
|
||||||
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
self._pipette_ctrl._update_tip_status()
|
||||||
if blow_out_air_volume >0:
|
if self._pipette_ctrl.tip_status == TipStatus.NO_TIP:
|
||||||
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
|
logger.warning("无枪头,无需丢弃")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||||
|
self._pipette_ctrl.eject_tip() # 修复: 原来缺少 ()
|
||||||
|
self._xyz.move_to_work_coord_safe(
|
||||||
|
z=self._xyz.machine_config.safe_z_height
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"drop_tips 失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ aspirate
|
||||||
|
|
||||||
|
async def aspirate(
|
||||||
|
self,
|
||||||
|
ops: List[SingleChannelAspiration],
|
||||||
|
use_channels: List[int],
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||||
|
|
||||||
async def dispense(
|
self._pipette_ctrl._update_tip_status()
|
||||||
self,
|
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
|
||||||
ops: List[SingleChannelDispense],
|
raise RuntimeError("无枪头,无法吸液")
|
||||||
use_channels: List[int],
|
|
||||||
**backend_kwargs,
|
|
||||||
):
|
|
||||||
# print("Dispensing:")
|
|
||||||
header = (
|
|
||||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
|
||||||
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
|
||||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
|
||||||
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
|
||||||
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
|
||||||
# f"{'liquids':<20}" # TODO: add liquids
|
|
||||||
)
|
|
||||||
for key in backend_kwargs:
|
|
||||||
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
|
|
||||||
# print(header)
|
|
||||||
|
|
||||||
for o, p in zip(ops, use_channels):
|
flow_rate = backend_kwargs.get("flow_rate", 500)
|
||||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
|
||||||
row = (
|
|
||||||
f" p{p}: "
|
|
||||||
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
|
||||||
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
|
||||||
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
|
||||||
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
|
||||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
|
||||||
)
|
|
||||||
for key, value in backend_kwargs.items():
|
|
||||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
|
||||||
value = "".join("T" if v else "F" for v in value)
|
|
||||||
if isinstance(value, list):
|
|
||||||
value = "".join(map(str, value))
|
|
||||||
row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
|
|
||||||
# print(row)
|
|
||||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
|
||||||
offset_xyz = ops[0].offset
|
|
||||||
x = coordinate.x + offset_xyz.x
|
|
||||||
y = coordinate.y + offset_xyz.y
|
|
||||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
|
||||||
# print(x, y, z)
|
|
||||||
# print("moving")
|
|
||||||
|
|
||||||
# 判断枪头是否存在
|
if (
|
||||||
self.hardware_interface._update_tip_status()
|
self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume
|
||||||
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
> self._pipette_ctrl.max_volume
|
||||||
print("无枪头,无法排液")
|
):
|
||||||
return
|
raise RuntimeError(
|
||||||
# 判断排液量是否超过枪头容量
|
f"吸液量超过枪头容量: "
|
||||||
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
|
f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}"
|
||||||
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
|
)
|
||||||
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
|
|
||||||
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||||
# 移动到排液位置
|
self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
|
||||||
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
|
||||||
|
|
||||||
|
self._xyz.move_to_work_coord_safe(
|
||||||
|
z=self._xyz.machine_config.safe_z_height
|
||||||
|
)
|
||||||
|
if blow_out_air_volume > 0:
|
||||||
|
self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||||
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
# ------------------------------------------------------------------ dispense
|
||||||
if blow_out_air_volume > 0:
|
|
||||||
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
|
|
||||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
|
|
||||||
|
|
||||||
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
async def dispense(
|
||||||
print(f"Picking up tips from {pickup.resource.name}.")
|
self,
|
||||||
|
ops: List[SingleChannelDispense],
|
||||||
|
use_channels: List[int],
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||||
|
|
||||||
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
self._pipette_ctrl._update_tip_status()
|
||||||
print(f"Dropping tips to {drop.resource.name}.")
|
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
|
||||||
|
raise RuntimeError("无枪头,无法排液")
|
||||||
|
|
||||||
async def aspirate96(
|
flow_rate = backend_kwargs.get("flow_rate", 500)
|
||||||
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
|
||||||
):
|
|
||||||
if isinstance(aspiration, MultiHeadAspirationPlate):
|
|
||||||
resource = aspiration.wells[0].parent
|
|
||||||
else:
|
|
||||||
resource = aspiration.container
|
|
||||||
print(f"Aspirating {aspiration.volume} from {resource}.")
|
|
||||||
|
|
||||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
if (
|
||||||
if isinstance(dispense, MultiHeadDispensePlate):
|
self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0
|
||||||
resource = dispense.wells[0].parent
|
):
|
||||||
else:
|
raise RuntimeError(
|
||||||
resource = dispense.container
|
f"排液量超过当前体积: "
|
||||||
print(f"Dispensing {dispense.volume} to {resource}.")
|
f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0"
|
||||||
|
)
|
||||||
|
|
||||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||||
print(f"Picking up resource: {pickup}")
|
self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
||||||
|
|
||||||
async def move_picked_up_resource(self, move: ResourceMove):
|
self._xyz.move_to_work_coord_safe(
|
||||||
print(f"Moving picked up resource: {move}")
|
z=self._xyz.machine_config.safe_z_height
|
||||||
|
)
|
||||||
|
if blow_out_air_volume > 0:
|
||||||
|
self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||||
|
|
||||||
async def drop_resource(self, drop: ResourceDrop):
|
# ------------------------------------------------------------------ 96-channel stubs
|
||||||
print(f"Dropping resource: {drop}")
|
|
||||||
|
|
||||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||||
return True
|
logger.info(f"Picking up tips from {pickup.resource.name}.")
|
||||||
|
|
||||||
|
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||||
|
logger.info(f"Dropping tips to {drop.resource.name}.")
|
||||||
|
|
||||||
|
async def aspirate96(
|
||||||
|
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||||
|
):
|
||||||
|
if isinstance(aspiration, MultiHeadAspirationPlate):
|
||||||
|
resource = aspiration.wells[0].parent
|
||||||
|
else:
|
||||||
|
resource = aspiration.container
|
||||||
|
logger.info(f"Aspirating {aspiration.volume} from {resource}.")
|
||||||
|
|
||||||
|
async def dispense96(
|
||||||
|
self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]
|
||||||
|
):
|
||||||
|
if isinstance(dispense, MultiHeadDispensePlate):
|
||||||
|
resource = dispense.wells[0].parent
|
||||||
|
else:
|
||||||
|
resource = dispense.container
|
||||||
|
logger.info(f"Dispensing {dispense.volume} to {resource}.")
|
||||||
|
|
||||||
|
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||||
|
logger.info(f"Picking up resource: {pickup}")
|
||||||
|
|
||||||
|
async def move_picked_up_resource(self, move: ResourceMove):
|
||||||
|
logger.info(f"Moving picked up resource: {move}")
|
||||||
|
|
||||||
|
async def drop_resource(self, drop: ResourceDrop):
|
||||||
|
logger.info(f"Dropping resource: {drop}")
|
||||||
|
|
||||||
|
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||||
|
return True
|
||||||
|
|||||||
@@ -5,21 +5,16 @@
|
|||||||
封装SOPA移液器的高级控制功能
|
封装SOPA移液器的高级控制功能
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 添加项目根目录到Python路径以解决模块导入问题
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from tkinter import N
|
|
||||||
|
_current_file = os.path.abspath(__file__)
|
||||||
|
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file)))))
|
||||||
|
if _project_root not in sys.path:
|
||||||
|
sys.path.insert(0, _project_root)
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
|
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
|
||||||
|
|
||||||
# 无论如何都添加项目根目录到路径
|
|
||||||
current_file = os.path.abspath(__file__)
|
|
||||||
# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py
|
|
||||||
# 向上5级到 .../Uni-Lab-OS
|
|
||||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
|
|
||||||
# 强制添加项目根目录到sys.path的开头
|
|
||||||
sys.path.insert(0, project_root)
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict, Tuple
|
from typing import Optional, List, Dict, Tuple
|
||||||
@@ -153,7 +148,7 @@ class PipetteController:
|
|||||||
logger.error("移液器连接失败")
|
logger.error("移液器连接失败")
|
||||||
return False
|
return False
|
||||||
logger.info("移液器连接成功")
|
logger.info("移液器连接成功")
|
||||||
|
|
||||||
# 连接XYZ步进电机控制器(如果提供了端口)
|
# 连接XYZ步进电机控制器(如果提供了端口)
|
||||||
if self.xyz_port != self.pipette_port:
|
if self.xyz_port != self.pipette_port:
|
||||||
try:
|
try:
|
||||||
@@ -172,24 +167,62 @@ class PipetteController:
|
|||||||
try:
|
try:
|
||||||
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
|
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
|
||||||
self.xyz_controller.serial_conn = self.pipette.serial_port
|
self.xyz_controller.serial_conn = self.pipette.serial_port
|
||||||
|
self.xyz_controller.serial_lock = self.pipette.lock
|
||||||
self.xyz_controller.is_connected = True
|
self.xyz_controller.is_connected = True
|
||||||
|
logger.info("XYZ控制器与移液器共享串口和互斥锁")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info("未配置XYZ步进电机端口,跳过运动控制器连接")
|
logger.warning(f"共享端口 XYZ 控制器创建失败: {e}")
|
||||||
|
self.xyz_controller = None
|
||||||
|
self.xyz_connected = False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"设备连接失败: {e}")
|
logger.error(f"设备连接失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool:
|
||||||
|
"""使用已连接的串口和XYZ控制器(路径 B 模式:XYZ 先开串口,移液器共享)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serial_conn: 已打开的串口连接(来自 XYZController)
|
||||||
|
serial_lock: 串口互斥锁(来自 XYZController)
|
||||||
|
xyz_controller: 已连接的 XYZController 实例
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.pipette.serial_port = serial_conn
|
||||||
|
self.pipette.lock = serial_lock
|
||||||
|
self.pipette.is_connected = True
|
||||||
|
|
||||||
|
self.xyz_controller = xyz_controller
|
||||||
|
self.xyz_connected = True
|
||||||
|
|
||||||
|
logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"connect_shared 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect_shared(self) -> None:
|
||||||
|
"""释放共享串口引用(与 connect_shared 对称)。
|
||||||
|
|
||||||
|
注意:不关闭串口本身,串口由 XYZController 负责关闭。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.pipette.serial_port = None
|
||||||
|
self.pipette.lock = None
|
||||||
|
self.pipette.is_connected = False
|
||||||
|
self.xyz_controller = None
|
||||||
|
self.xyz_connected = False
|
||||||
|
logger.info("移液控制器已释放共享串口引用")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"disconnect_shared 失败: {e}")
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
def initialize(self) -> bool:
|
||||||
"""初始化移液器"""
|
"""初始化移液器"""
|
||||||
try:
|
try:
|
||||||
if self.pipette.initialize():
|
if self.pipette.initialize():
|
||||||
logger.info("移液器初始化成功")
|
logger.info("移液器初始化成功")
|
||||||
# 检查枪头状态
|
|
||||||
self._update_tip_status()
|
self._update_tip_status()
|
||||||
self.xyz_controller.home_all_axes()
|
|
||||||
self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0)
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -198,56 +231,58 @@ class PipetteController:
|
|||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
"""断开连接"""
|
"""断开连接"""
|
||||||
# 断开移液器连接
|
if self.xyz_controller and self.xyz_connected:
|
||||||
|
if self.xyz_port != self.pipette_port:
|
||||||
|
try:
|
||||||
|
self.xyz_controller.disconnect()
|
||||||
|
logger.info("XYZ 步进电机已断开")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"断开 XYZ 步进电机失败: {e}")
|
||||||
|
else:
|
||||||
|
self.xyz_controller.serial_conn = None
|
||||||
|
self.xyz_connected = False
|
||||||
|
self.xyz_controller = None
|
||||||
|
|
||||||
self.pipette.disconnect()
|
self.pipette.disconnect()
|
||||||
logger.info("移液器已断开")
|
logger.info("移液器已断开")
|
||||||
|
|
||||||
# 断开 XYZ 步进电机连接
|
|
||||||
if self.xyz_controller and self.xyz_connected:
|
|
||||||
try:
|
|
||||||
self.xyz_controller.disconnect()
|
|
||||||
self.xyz_connected = False
|
|
||||||
logger.info("XYZ 步进电机已断开")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"断开 XYZ 步进电机失败: {e}")
|
|
||||||
|
|
||||||
def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
|
def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
|
||||||
"""
|
"""
|
||||||
检查 XYZ 轴移动的安全性
|
检查 XYZ 轴移动的安全性
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
axis: 电机轴
|
axis: 电机轴
|
||||||
target_position: 目标位置(步数)
|
target_position: 目标位置(步数)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
是否安全
|
是否安全
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 获取当前电机状态
|
# 获取当前电机状态
|
||||||
motor_position = self.xyz_controller.get_motor_status(axis)
|
motor_position = self.xyz_controller.get_motor_status(axis)
|
||||||
|
|
||||||
# 检查电机状态是否正常 (不是碰撞停止或限位停止)
|
# 检查电机状态是否正常 (不是碰撞停止或限位停止)
|
||||||
if motor_position.status in [MotorStatus.COLLISION_STOP,
|
if motor_position.status in [MotorStatus.COLLISION_STOP,
|
||||||
MotorStatus.FORWARD_LIMIT_STOP,
|
MotorStatus.FORWARD_LIMIT_STOP,
|
||||||
MotorStatus.REVERSE_LIMIT_STOP]:
|
MotorStatus.REVERSE_LIMIT_STOP]:
|
||||||
logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}")
|
logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 检查位置限制 (扩大安全范围以适应实际硬件)
|
# 检查位置限制 (扩大安全范围以适应实际硬件)
|
||||||
# 步进电机的位置范围通常很大,这里设置更合理的范围
|
# 步进电机的位置范围通常很大,这里设置更合理的范围
|
||||||
if target_position < -500000 or target_position > 500000:
|
if target_position < -500000 or target_position > 500000:
|
||||||
logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}")
|
logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 检查移动距离是否过大 (单次移动不超过 20000 步,约12mm)
|
# 检查移动距离是否过大 (单次移动不超过 20000 步,约12mm)
|
||||||
current_position = motor_position.steps
|
current_position = motor_position.steps
|
||||||
move_distance = abs(target_position - current_position)
|
move_distance = abs(target_position - current_position)
|
||||||
if move_distance > 20000:
|
if move_distance > 20000:
|
||||||
logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}步")
|
logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}步")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"安全检查失败: {e}")
|
logger.error(f"安全检查失败: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -255,48 +290,48 @@ class PipetteController:
|
|||||||
def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool:
|
def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool:
|
||||||
"""
|
"""
|
||||||
Z轴相对移动
|
Z轴相对移动
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
distance_mm: 移动距离(mm),正值向下,负值向上
|
distance_mm: 移动距离(mm),正值向下,负值向上
|
||||||
speed: 移动速度(rpm)
|
speed: 移动速度(rpm)
|
||||||
acceleration: 加速度(rpm/s)
|
acceleration: 加速度(rpm/s)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
移动是否成功
|
移动是否成功
|
||||||
"""
|
"""
|
||||||
if not self.xyz_controller or not self.xyz_connected:
|
if not self.xyz_controller or not self.xyz_connected:
|
||||||
logger.error("XYZ 步进电机未连接,无法执行移动")
|
logger.error("XYZ 步进电机未连接,无法执行移动")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 参数验证
|
# 参数验证
|
||||||
if abs(distance_mm) > 15.0:
|
if abs(distance_mm) > 15.0:
|
||||||
logger.error(f"移动距离过大: {distance_mm}mm,最大允许15mm")
|
logger.error(f"移动距离过大: {distance_mm}mm,最大允许15mm")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if speed < 100 or speed > 5000:
|
if speed < 100 or speed > 5000:
|
||||||
logger.error(f"速度参数无效: {speed}rpm,范围应为100-5000")
|
logger.error(f"速度参数无效: {speed}rpm,范围应为100-5000")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 获取当前 Z 轴位置
|
# 获取当前 Z 轴位置
|
||||||
current_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
|
current_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
|
||||||
current_z_position = current_status.steps
|
current_z_position = current_status.steps
|
||||||
|
|
||||||
# 计算移动距离对应的步数 (1mm = 1638.4步)
|
# 计算移动距离对应的步数 (1mm = 1638.4步)
|
||||||
mm_to_steps = 1638.4
|
mm_to_steps = 1638.4
|
||||||
move_distance_steps = int(distance_mm * mm_to_steps)
|
move_distance_steps = int(distance_mm * mm_to_steps)
|
||||||
|
|
||||||
# 计算目标位置
|
# 计算目标位置
|
||||||
target_z_position = current_z_position + move_distance_steps
|
target_z_position = current_z_position + move_distance_steps
|
||||||
|
|
||||||
# 安全检查
|
# 安全检查
|
||||||
if not self._check_xyz_safety(MotorAxis.Z, target_z_position):
|
if not self._check_xyz_safety(MotorAxis.Z, target_z_position):
|
||||||
logger.error("Z轴移动安全检查失败")
|
logger.error("Z轴移动安全检查失败")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)")
|
logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)")
|
||||||
logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}步")
|
logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}步")
|
||||||
|
|
||||||
# 执行移动
|
# 执行移动
|
||||||
success = self.xyz_controller.move_to_position(
|
success = self.xyz_controller.move_to_position(
|
||||||
axis=MotorAxis.Z,
|
axis=MotorAxis.Z,
|
||||||
@@ -305,28 +340,28 @@ class PipetteController:
|
|||||||
acceleration=acceleration,
|
acceleration=acceleration,
|
||||||
precision=50
|
precision=50
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
logger.error("Z轴移动命令发送失败")
|
logger.error("Z轴移动命令发送失败")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 等待移动完成
|
# 等待移动完成
|
||||||
if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0):
|
if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0):
|
||||||
logger.error("Z轴移动超时")
|
logger.error("Z轴移动超时")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 验证移动结果
|
# 验证移动结果
|
||||||
final_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
|
final_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
|
||||||
final_position = final_status.steps
|
final_position = final_status.steps
|
||||||
position_error = abs(final_position - target_z_position)
|
position_error = abs(final_position - target_z_position)
|
||||||
|
|
||||||
logger.info(f"Z轴移动完成,最终位置: {final_position}步,误差: {position_error}步")
|
logger.info(f"Z轴移动完成,最终位置: {final_position}步,误差: {position_error}步")
|
||||||
|
|
||||||
if position_error > 100:
|
if position_error > 100:
|
||||||
logger.warning(f"Z轴位置误差较大: {position_error}步")
|
logger.warning(f"Z轴位置误差较大: {position_error}步")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except ModbusException as e:
|
except ModbusException as e:
|
||||||
logger.error(f"Modbus通信错误: {e}")
|
logger.error(f"Modbus通信错误: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -337,21 +372,20 @@ class PipetteController:
|
|||||||
def emergency_stop(self) -> bool:
|
def emergency_stop(self) -> bool:
|
||||||
"""
|
"""
|
||||||
紧急停止所有运动
|
紧急停止所有运动
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
停止是否成功
|
停止是否成功
|
||||||
"""
|
"""
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
# 停止移液器操作
|
|
||||||
try:
|
try:
|
||||||
if self.pipette and self.connected:
|
if self.pipette and self.pipette.is_connected:
|
||||||
# 这里可以添加移液器的紧急停止逻辑
|
self.pipette.emergency_stop()
|
||||||
logger.info("移液器紧急停止")
|
logger.info("移液器紧急停止")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"移液器紧急停止失败: {e}")
|
logger.error(f"移液器紧急停止失败: {e}")
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
# 停止 XYZ 轴运动
|
# 停止 XYZ 轴运动
|
||||||
try:
|
try:
|
||||||
if self.xyz_controller and self.xyz_connected:
|
if self.xyz_controller and self.xyz_connected:
|
||||||
@@ -360,7 +394,7 @@ class PipetteController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"XYZ 轴紧急停止失败: {e}")
|
logger.error(f"XYZ 轴紧急停止失败: {e}")
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
def pickup_tip(self) -> bool:
|
def pickup_tip(self) -> bool:
|
||||||
@@ -376,7 +410,7 @@ class PipetteController:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
logger.info("开始装载枪头 - Z轴向下移动10mm")
|
logger.info("开始装载枪头 - Z轴向下移动10mm")
|
||||||
|
|
||||||
# 使用相对移动方法,向下移动10mm
|
# 使用相对移动方法,向下移动10mm
|
||||||
if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500):
|
if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500):
|
||||||
# 更新枪头状态
|
# 更新枪头状态
|
||||||
@@ -688,31 +722,31 @@ class PipetteController:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# 配置日志
|
# 配置日志
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# 设置日志级别
|
# 设置日志级别
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
)
|
)
|
||||||
|
|
||||||
def interactive_test():
|
def interactive_test():
|
||||||
"""交互式测试模式 - 适用于已连接的设备"""
|
"""交互式测试模式 - 适用于已连接的设备"""
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("🧪 移液器交互式测试模式")
|
print("🧪 移液器交互式测试模式")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
# 获取用户输入的连接参数
|
# 获取用户输入的连接参数
|
||||||
print("\n📡 设备连接配置:")
|
print("\n📡 设备连接配置:")
|
||||||
port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
|
port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
|
||||||
address_input = input("请输入移液器设备地址 (默认: 4): ").strip()
|
address_input = input("请输入移液器设备地址 (默认: 4): ").strip()
|
||||||
address = int(address_input) if address_input else 4
|
address = int(address_input) if address_input else 4
|
||||||
|
|
||||||
# 询问是否连接 XYZ 步进电机控制器
|
# 询问是否连接 XYZ 步进电机控制器
|
||||||
xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower()
|
xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower()
|
||||||
xyz_port = None
|
xyz_port = None
|
||||||
if xyz_enable not in ['n', 'no']:
|
if xyz_enable not in ['n', 'no']:
|
||||||
xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
|
xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建移液控制器实例
|
# 创建移液控制器实例
|
||||||
if xyz_port:
|
if xyz_port:
|
||||||
@@ -721,21 +755,21 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...")
|
print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...")
|
||||||
pipette = PipetteController(port=port, address=address)
|
pipette = PipetteController(port=port, address=address)
|
||||||
|
|
||||||
# 连接设备
|
# 连接设备
|
||||||
print("\n📞 连接移液器设备...")
|
print("\n📞 连接移液器设备...")
|
||||||
if not pipette.connect():
|
if not pipette.connect():
|
||||||
print("❌ 设备连接失败,请检查连接")
|
print("❌ 设备连接失败,请检查连接")
|
||||||
return
|
return
|
||||||
print("✅ 设备连接成功")
|
print("✅ 设备连接成功")
|
||||||
|
|
||||||
# 初始化设备
|
# 初始化设备
|
||||||
print("\n🚀 初始化设备...")
|
print("\n🚀 初始化设备...")
|
||||||
if not pipette.initialize():
|
if not pipette.initialize():
|
||||||
print("❌ 设备初始化失败")
|
print("❌ 设备初始化失败")
|
||||||
return
|
return
|
||||||
print("✅ 设备初始化成功")
|
print("✅ 设备初始化成功")
|
||||||
|
|
||||||
# 交互式菜单
|
# 交互式菜单
|
||||||
while True:
|
while True:
|
||||||
print("\n" + "=" * 50)
|
print("\n" + "=" * 50)
|
||||||
@@ -755,9 +789,9 @@ if __name__ == "__main__":
|
|||||||
print("99. 🚨 紧急停止")
|
print("99. 🚨 紧急停止")
|
||||||
print("0. 🚪 退出程序")
|
print("0. 🚪 退出程序")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
choice = input("\n请选择操作 (0-12, 99): ").strip()
|
choice = input("\n请选择操作 (0-12, 99): ").strip()
|
||||||
|
|
||||||
if choice == "0":
|
if choice == "0":
|
||||||
print("\n👋 退出程序...")
|
print("\n👋 退出程序...")
|
||||||
break
|
break
|
||||||
@@ -773,7 +807,7 @@ if __name__ == "__main__":
|
|||||||
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
|
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
|
||||||
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
|
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
|
||||||
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
|
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
|
||||||
|
|
||||||
elif choice == "2":
|
elif choice == "2":
|
||||||
# 装载枪头
|
# 装载枪头
|
||||||
print("\n🔧 装载枪头...")
|
print("\n🔧 装载枪头...")
|
||||||
@@ -781,14 +815,14 @@ if __name__ == "__main__":
|
|||||||
print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)")
|
print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)")
|
||||||
else:
|
else:
|
||||||
print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载")
|
print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载")
|
||||||
|
|
||||||
if pipette.pickup_tip():
|
if pipette.pickup_tip():
|
||||||
print("✅ 枪头装载成功")
|
print("✅ 枪头装载成功")
|
||||||
if pipette.xyz_connected:
|
if pipette.xyz_connected:
|
||||||
print("📍 Z 轴已移动到装载位置")
|
print("📍 Z 轴已移动到装载位置")
|
||||||
else:
|
else:
|
||||||
print("❌ 枪头装载失败")
|
print("❌ 枪头装载失败")
|
||||||
|
|
||||||
elif choice == "3":
|
elif choice == "3":
|
||||||
# 弹出枪头
|
# 弹出枪头
|
||||||
print("\n🗑️ 弹出枪头...")
|
print("\n🗑️ 弹出枪头...")
|
||||||
@@ -796,7 +830,7 @@ if __name__ == "__main__":
|
|||||||
print("✅ 枪头弹出成功")
|
print("✅ 枪头弹出成功")
|
||||||
else:
|
else:
|
||||||
print("❌ 枪头弹出失败")
|
print("❌ 枪头弹出失败")
|
||||||
|
|
||||||
elif choice == "4":
|
elif choice == "4":
|
||||||
# 吸液操作
|
# 吸液操作
|
||||||
try:
|
try:
|
||||||
@@ -810,7 +844,7 @@ if __name__ == "__main__":
|
|||||||
print("❌ 吸液失败")
|
print("❌ 吸液失败")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("❌ 请输入有效的数字")
|
print("❌ 请输入有效的数字")
|
||||||
|
|
||||||
elif choice == "5":
|
elif choice == "5":
|
||||||
# 排液操作
|
# 排液操作
|
||||||
try:
|
try:
|
||||||
@@ -824,7 +858,7 @@ if __name__ == "__main__":
|
|||||||
print("❌ 排液失败")
|
print("❌ 排液失败")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("❌ 请输入有效的数字")
|
print("❌ 请输入有效的数字")
|
||||||
|
|
||||||
elif choice == "6":
|
elif choice == "6":
|
||||||
# 混合操作
|
# 混合操作
|
||||||
try:
|
try:
|
||||||
@@ -838,7 +872,7 @@ if __name__ == "__main__":
|
|||||||
print("❌ 混合失败")
|
print("❌ 混合失败")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("❌ 请输入有效的数字")
|
print("❌ 请输入有效的数字")
|
||||||
|
|
||||||
elif choice == "7":
|
elif choice == "7":
|
||||||
# 液体转移
|
# 液体转移
|
||||||
try:
|
try:
|
||||||
@@ -846,7 +880,7 @@ if __name__ == "__main__":
|
|||||||
source = input("源孔位 (可选, 如A1): ").strip() or None
|
source = input("源孔位 (可选, 如A1): ").strip() or None
|
||||||
dest = input("目标孔位 (可选, 如B1): ").strip() or None
|
dest = input("目标孔位 (可选, 如B1): ").strip() or None
|
||||||
new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n'
|
new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n'
|
||||||
|
|
||||||
print(f"\n🔄 执行液体转移 ({volume}ul)...")
|
print(f"\n🔄 执行液体转移 ({volume}ul)...")
|
||||||
if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip):
|
if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip):
|
||||||
print("✅ 液体转移完成")
|
print("✅ 液体转移完成")
|
||||||
@@ -854,7 +888,7 @@ if __name__ == "__main__":
|
|||||||
print("❌ 液体转移失败")
|
print("❌ 液体转移失败")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("❌ 请输入有效的数字")
|
print("❌ 请输入有效的数字")
|
||||||
|
|
||||||
elif choice == "8":
|
elif choice == "8":
|
||||||
# 设置液体类型
|
# 设置液体类型
|
||||||
print("\n🧪 可用液体类型:")
|
print("\n🧪 可用液体类型:")
|
||||||
@@ -864,16 +898,16 @@ if __name__ == "__main__":
|
|||||||
"3": (LiquidClass.VISCOUS, "粘稠液体"),
|
"3": (LiquidClass.VISCOUS, "粘稠液体"),
|
||||||
"4": (LiquidClass.VOLATILE, "挥发性液体")
|
"4": (LiquidClass.VOLATILE, "挥发性液体")
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, (liquid_class, description) in liquid_options.items():
|
for key, (liquid_class, description) in liquid_options.items():
|
||||||
print(f" {key}. {description}")
|
print(f" {key}. {description}")
|
||||||
|
|
||||||
liquid_choice = input("请选择液体类型 (1-4): ").strip()
|
liquid_choice = input("请选择液体类型 (1-4): ").strip()
|
||||||
if liquid_choice in liquid_options:
|
if liquid_choice in liquid_options:
|
||||||
liquid_class, description = liquid_options[liquid_choice]
|
liquid_class, description = liquid_options[liquid_choice]
|
||||||
pipette.set_liquid_class(liquid_class)
|
pipette.set_liquid_class(liquid_class)
|
||||||
print(f"✅ 液体类型设置为: {description}")
|
print(f"✅ 液体类型设置为: {description}")
|
||||||
|
|
||||||
# 显示参数
|
# 显示参数
|
||||||
params = pipette.liquid_params
|
params = pipette.liquid_params
|
||||||
print(f"📋 参数设置:")
|
print(f"📋 参数设置:")
|
||||||
@@ -883,7 +917,7 @@ if __name__ == "__main__":
|
|||||||
print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}")
|
print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}")
|
||||||
else:
|
else:
|
||||||
print("❌ 无效选择")
|
print("❌ 无效选择")
|
||||||
|
|
||||||
elif choice == "9":
|
elif choice == "9":
|
||||||
# 自定义参数
|
# 自定义参数
|
||||||
try:
|
try:
|
||||||
@@ -892,19 +926,19 @@ if __name__ == "__main__":
|
|||||||
dispense_speed = input("排液速度 (默认800): ").strip()
|
dispense_speed = input("排液速度 (默认800): ").strip()
|
||||||
air_gap = input("空气间隙 (ul, 默认10.0): ").strip()
|
air_gap = input("空气间隙 (ul, 默认10.0): ").strip()
|
||||||
pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y'
|
pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y'
|
||||||
|
|
||||||
custom_params = LiquidParameters(
|
custom_params = LiquidParameters(
|
||||||
aspirate_speed=int(aspirate_speed) if aspirate_speed else 500,
|
aspirate_speed=int(aspirate_speed) if aspirate_speed else 500,
|
||||||
dispense_speed=int(dispense_speed) if dispense_speed else 800,
|
dispense_speed=int(dispense_speed) if dispense_speed else 800,
|
||||||
air_gap=float(air_gap) if air_gap else 10.0,
|
air_gap=float(air_gap) if air_gap else 10.0,
|
||||||
pre_wet=pre_wet
|
pre_wet=pre_wet
|
||||||
)
|
)
|
||||||
|
|
||||||
pipette.set_custom_parameters(custom_params)
|
pipette.set_custom_parameters(custom_params)
|
||||||
print("✅ 自定义参数设置完成")
|
print("✅ 自定义参数设置完成")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("❌ 请输入有效的数字")
|
print("❌ 请输入有效的数字")
|
||||||
|
|
||||||
elif choice == "10":
|
elif choice == "10":
|
||||||
# 校准体积
|
# 校准体积
|
||||||
try:
|
try:
|
||||||
@@ -914,12 +948,12 @@ if __name__ == "__main__":
|
|||||||
print(f"✅ 校准完成,校准系数: {actual/expected:.3f}")
|
print(f"✅ 校准完成,校准系数: {actual/expected:.3f}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("❌ 请输入有效的数字")
|
print("❌ 请输入有效的数字")
|
||||||
|
|
||||||
elif choice == "11":
|
elif choice == "11":
|
||||||
# 重置统计
|
# 重置统计
|
||||||
pipette.reset_statistics()
|
pipette.reset_statistics()
|
||||||
print("✅ 统计信息已重置")
|
print("✅ 统计信息已重置")
|
||||||
|
|
||||||
elif choice == "12":
|
elif choice == "12":
|
||||||
# 液体类型测试
|
# 液体类型测试
|
||||||
print("\n🧪 液体类型参数对比:")
|
print("\n🧪 液体类型参数对比:")
|
||||||
@@ -929,7 +963,7 @@ if __name__ == "__main__":
|
|||||||
(LiquidClass.VISCOUS, "粘稠液体"),
|
(LiquidClass.VISCOUS, "粘稠液体"),
|
||||||
(LiquidClass.VOLATILE, "挥发性液体")
|
(LiquidClass.VOLATILE, "挥发性液体")
|
||||||
]
|
]
|
||||||
|
|
||||||
for liquid_class, description in liquid_tests:
|
for liquid_class, description in liquid_tests:
|
||||||
params = pipette.LIQUID_PARAMS[liquid_class]
|
params = pipette.LIQUID_PARAMS[liquid_class]
|
||||||
print(f"\n📋 {description} ({liquid_class.value}):")
|
print(f"\n📋 {description} ({liquid_class.value}):")
|
||||||
@@ -938,7 +972,7 @@ if __name__ == "__main__":
|
|||||||
print(f" 💨 空气间隙: {params.air_gap}ul")
|
print(f" 💨 空气间隙: {params.air_gap}ul")
|
||||||
print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}")
|
print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}")
|
||||||
print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s")
|
print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s")
|
||||||
|
|
||||||
elif choice == "99":
|
elif choice == "99":
|
||||||
# 紧急停止
|
# 紧急停止
|
||||||
print("\n🚨 执行紧急停止...")
|
print("\n🚨 执行紧急停止...")
|
||||||
@@ -949,19 +983,19 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print("❌ 紧急停止执行失败")
|
print("❌ 紧急停止执行失败")
|
||||||
print("⚠️ 请手动检查设备状态并采取必要措施")
|
print("⚠️ 请手动检查设备状态并采取必要措施")
|
||||||
|
|
||||||
# 紧急停止后询问是否继续
|
# 紧急停止后询问是否继续
|
||||||
continue_choice = input("\n是否继续操作?(y/n): ").strip().lower()
|
continue_choice = input("\n是否继续操作?(y/n): ").strip().lower()
|
||||||
if continue_choice != 'y':
|
if continue_choice != 'y':
|
||||||
print("🚪 退出程序")
|
print("🚪 退出程序")
|
||||||
break
|
break
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("❌ 无效选择,请重新输入")
|
print("❌ 无效选择,请重新输入")
|
||||||
|
|
||||||
# 等待用户确认继续
|
# 等待用户确认继续
|
||||||
input("\n按回车键继续...")
|
input("\n按回车键继续...")
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n\n⚠️ 用户中断操作")
|
print("\n\n⚠️ 用户中断操作")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -974,19 +1008,19 @@ if __name__ == "__main__":
|
|||||||
print("✅ 连接已断开")
|
print("✅ 连接已断开")
|
||||||
except:
|
except:
|
||||||
print("⚠️ 断开连接时出现问题")
|
print("⚠️ 断开连接时出现问题")
|
||||||
|
|
||||||
def demo_test():
|
def demo_test():
|
||||||
"""演示测试模式 - 完整功能演示"""
|
"""演示测试模式 - 完整功能演示"""
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("🎬 移液控制器演示测试")
|
print("🎬 移液控制器演示测试")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建移液控制器实例
|
# 创建移液控制器实例
|
||||||
print("1. 🔧 创建移液控制器实例...")
|
print("1. 🔧 创建移液控制器实例...")
|
||||||
pipette = PipetteController(port="/dev/ttyUSB0", address=4)
|
pipette = PipetteController(port="/dev/ttyUSB0", address=4)
|
||||||
print("✅ 移液控制器实例创建成功")
|
print("✅ 移液控制器实例创建成功")
|
||||||
|
|
||||||
# 连接设备
|
# 连接设备
|
||||||
print("\n2. 📞 连接移液器设备...")
|
print("\n2. 📞 连接移液器设备...")
|
||||||
if pipette.connect():
|
if pipette.connect():
|
||||||
@@ -994,7 +1028,7 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print("❌ 设备连接失败")
|
print("❌ 设备连接失败")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 初始化设备
|
# 初始化设备
|
||||||
print("\n3. 🚀 初始化设备...")
|
print("\n3. 🚀 初始化设备...")
|
||||||
if pipette.initialize():
|
if pipette.initialize():
|
||||||
@@ -1002,19 +1036,19 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print("❌ 设备初始化失败")
|
print("❌ 设备初始化失败")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 装载枪头
|
# 装载枪头
|
||||||
print("\n4. 🔧 装载枪头...")
|
print("\n4. 🔧 装载枪头...")
|
||||||
if pipette.pickup_tip():
|
if pipette.pickup_tip():
|
||||||
print("✅ 枪头装载成功")
|
print("✅ 枪头装载成功")
|
||||||
else:
|
else:
|
||||||
print("❌ 枪头装载失败")
|
print("❌ 枪头装载失败")
|
||||||
|
|
||||||
# 设置液体类型
|
# 设置液体类型
|
||||||
print("\n5. 🧪 设置液体类型为血清...")
|
print("\n5. 🧪 设置液体类型为血清...")
|
||||||
pipette.set_liquid_class(LiquidClass.SERUM)
|
pipette.set_liquid_class(LiquidClass.SERUM)
|
||||||
print("✅ 液体类型设置完成")
|
print("✅ 液体类型设置完成")
|
||||||
|
|
||||||
# 吸液操作
|
# 吸液操作
|
||||||
print("\n6. 💧 执行吸液操作...")
|
print("\n6. 💧 执行吸液操作...")
|
||||||
volume_to_aspirate = 100.0
|
volume_to_aspirate = 100.0
|
||||||
@@ -1023,7 +1057,7 @@ if __name__ == "__main__":
|
|||||||
print(f"📊 当前体积: {pipette.current_volume}ul")
|
print(f"📊 当前体积: {pipette.current_volume}ul")
|
||||||
else:
|
else:
|
||||||
print("❌ 吸液失败")
|
print("❌ 吸液失败")
|
||||||
|
|
||||||
# 排液操作
|
# 排液操作
|
||||||
print("\n7. 💦 执行排液操作...")
|
print("\n7. 💦 执行排液操作...")
|
||||||
volume_to_dispense = 50.0
|
volume_to_dispense = 50.0
|
||||||
@@ -1032,14 +1066,14 @@ if __name__ == "__main__":
|
|||||||
print(f"📊 剩余体积: {pipette.current_volume}ul")
|
print(f"📊 剩余体积: {pipette.current_volume}ul")
|
||||||
else:
|
else:
|
||||||
print("❌ 排液失败")
|
print("❌ 排液失败")
|
||||||
|
|
||||||
# 混合操作
|
# 混合操作
|
||||||
print("\n8. 🌀 执行混合操作...")
|
print("\n8. 🌀 执行混合操作...")
|
||||||
if pipette.mix(cycles=3, volume=30.0):
|
if pipette.mix(cycles=3, volume=30.0):
|
||||||
print("✅ 混合完成")
|
print("✅ 混合完成")
|
||||||
else:
|
else:
|
||||||
print("❌ 混合失败")
|
print("❌ 混合失败")
|
||||||
|
|
||||||
# 获取状态信息
|
# 获取状态信息
|
||||||
print("\n9. 📊 获取设备状态...")
|
print("\n9. 📊 获取设备状态...")
|
||||||
status = pipette.get_status()
|
status = pipette.get_status()
|
||||||
@@ -1052,30 +1086,30 @@ if __name__ == "__main__":
|
|||||||
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
|
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
|
||||||
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
|
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
|
||||||
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
|
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
|
||||||
|
|
||||||
# 弹出枪头
|
# 弹出枪头
|
||||||
print("\n10. 🗑️ 弹出枪头...")
|
print("\n10. 🗑️ 弹出枪头...")
|
||||||
if pipette.eject_tip():
|
if pipette.eject_tip():
|
||||||
print("✅ 枪头弹出成功")
|
print("✅ 枪头弹出成功")
|
||||||
else:
|
else:
|
||||||
print("❌ 枪头弹出失败")
|
print("❌ 枪头弹出失败")
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("✅ 移液控制器演示测试完成")
|
print("✅ 移液控制器演示测试完成")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ 测试过程中发生异常: {e}")
|
print(f"\n❌ 测试过程中发生异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 断开连接
|
# 断开连接
|
||||||
print("\n📞 断开连接...")
|
print("\n📞 断开连接...")
|
||||||
pipette.disconnect()
|
pipette.disconnect()
|
||||||
print("✅ 连接已断开")
|
print("✅ 连接已断开")
|
||||||
|
|
||||||
# 主程序入口
|
# 主程序入口
|
||||||
print("🧪 移液器控制器测试程序")
|
print("🧪 移液器控制器测试程序")
|
||||||
print("=" * 40)
|
print("=" * 40)
|
||||||
@@ -1083,9 +1117,9 @@ if __name__ == "__main__":
|
|||||||
print("2. 🎬 演示测试")
|
print("2. 🎬 演示测试")
|
||||||
print("0. 🚪 退出")
|
print("0. 🚪 退出")
|
||||||
print("=" * 40)
|
print("=" * 40)
|
||||||
|
|
||||||
mode = input("请选择测试模式 (0-2): ").strip()
|
mode = input("请选择测试模式 (0-2): ").strip()
|
||||||
|
|
||||||
if mode == "1":
|
if mode == "1":
|
||||||
interactive_test()
|
interactive_test()
|
||||||
elif mode == "2":
|
elif mode == "2":
|
||||||
@@ -1094,7 +1128,7 @@ if __name__ == "__main__":
|
|||||||
print("👋 再见!")
|
print("👋 再见!")
|
||||||
else:
|
else:
|
||||||
print("❌ 无效选择")
|
print("❌ 无效选择")
|
||||||
|
|
||||||
print("\n🎉 程序结束!")
|
print("\n🎉 程序结束!")
|
||||||
print("\n💡 使用说明:")
|
print("\n💡 使用说明:")
|
||||||
print("1. 确保移液器硬件已正确连接")
|
print("1. 确保移液器硬件已正确连接")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pylabrobot.liquid_handling import (
|
|||||||
SingleChannelDispense,
|
SingleChannelDispense,
|
||||||
PickupTipRack,
|
PickupTipRack,
|
||||||
DropTipRack,
|
DropTipRack,
|
||||||
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
|
MultiHeadAspirationPlate,
|
||||||
)
|
)
|
||||||
from pylabrobot.liquid_handling.standard import (
|
from pylabrobot.liquid_handling.standard import (
|
||||||
MultiHeadAspirationContainer,
|
MultiHeadAspirationContainer,
|
||||||
@@ -41,12 +41,6 @@ class TransformXYZDeck(Deck):
|
|||||||
super().__init__(name, size_x, size_y, size_z)
|
super().__init__(name, size_x, size_y, size_z)
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
class TransformXYZBackend(LiquidHandlerBackend):
|
|
||||||
def __init__(self, name: str, host: str, port: int, timeout: float):
|
|
||||||
super().__init__()
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
|
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
|
||||||
def __init__(self, name: str, channel_num: int):
|
def __init__(self, name: str, channel_num: int):
|
||||||
@@ -86,7 +80,9 @@ class TransformXYZContainer(Plate, TipRack):
|
|||||||
class TransformXYZHandler(LiquidHandlerAbstract):
|
class TransformXYZHandler(LiquidHandlerAbstract):
|
||||||
support_touch_tip = False
|
support_touch_tip = False
|
||||||
|
|
||||||
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs):
|
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True,
|
||||||
|
serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4,
|
||||||
|
total_height: float = 310, **backend_kwargs):
|
||||||
# Handle case where deck is passed as a dict (from serialization)
|
# Handle case where deck is passed as a dict (from serialization)
|
||||||
if isinstance(deck, dict):
|
if isinstance(deck, dict):
|
||||||
# Try to create a TransformXYZDeck from the dict
|
# Try to create a TransformXYZDeck from the dict
|
||||||
@@ -102,11 +98,22 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
|
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
|
||||||
|
|
||||||
if simulator:
|
if simulator:
|
||||||
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num)
|
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num)
|
||||||
else:
|
else:
|
||||||
self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout)
|
self._unilabos_backend = UniLiquidHandlerLaiyuBackend(
|
||||||
|
num_channels=channel_num,
|
||||||
|
total_height=total_height,
|
||||||
|
port=serial_port,
|
||||||
|
baudrate=baudrate,
|
||||||
|
pipette_address=pipette_address,
|
||||||
|
)
|
||||||
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
|
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
|
||||||
|
|
||||||
|
def post_init(self, ros_node):
|
||||||
|
super().post_init(ros_node)
|
||||||
|
if hasattr(self._unilabos_backend, 'post_init'):
|
||||||
|
self._unilabos_backend.post_init(ros_node)
|
||||||
|
|
||||||
async def add_liquid(
|
async def add_liquid(
|
||||||
self,
|
self,
|
||||||
asp_vols: Union[List[float], float],
|
asp_vols: Union[List[float], float],
|
||||||
@@ -128,7 +135,25 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
mix_liquid_height: Optional[float] = None,
|
mix_liquid_height: Optional[float] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
):
|
):
|
||||||
pass
|
return await super().add_liquid(
|
||||||
|
asp_vols=asp_vols,
|
||||||
|
dis_vols=dis_vols,
|
||||||
|
reagent_sources=reagent_sources,
|
||||||
|
targets=targets,
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=flow_rates,
|
||||||
|
offsets=offsets,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
is_96_well=is_96_well,
|
||||||
|
delays=delays,
|
||||||
|
mix_time=mix_time,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
mix_rate=mix_rate,
|
||||||
|
mix_liquid_height=mix_liquid_height,
|
||||||
|
none_keys=none_keys,
|
||||||
|
)
|
||||||
|
|
||||||
async def aspirate(
|
async def aspirate(
|
||||||
self,
|
self,
|
||||||
@@ -142,7 +167,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
pass
|
return await super().aspirate(
|
||||||
|
resources=resources,
|
||||||
|
vols=vols,
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=flow_rates,
|
||||||
|
offsets=offsets,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
async def dispense(
|
async def dispense(
|
||||||
self,
|
self,
|
||||||
@@ -156,7 +191,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
pass
|
return await super().dispense(
|
||||||
|
resources=resources,
|
||||||
|
vols=vols,
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=flow_rates,
|
||||||
|
offsets=offsets,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
async def drop_tips(
|
async def drop_tips(
|
||||||
self,
|
self,
|
||||||
@@ -166,7 +211,13 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
allow_nonzero_volume: bool = False,
|
allow_nonzero_volume: bool = False,
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
pass
|
return await super().drop_tips(
|
||||||
|
tip_spots=tip_spots,
|
||||||
|
use_channels=use_channels,
|
||||||
|
offsets=offsets,
|
||||||
|
allow_nonzero_volume=allow_nonzero_volume,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
async def mix(
|
async def mix(
|
||||||
self,
|
self,
|
||||||
@@ -178,7 +229,15 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
mix_rate: Optional[float] = None,
|
mix_rate: Optional[float] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
):
|
):
|
||||||
pass
|
return await super().mix(
|
||||||
|
targets=targets,
|
||||||
|
mix_time=mix_time,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
height_to_bottom=height_to_bottom,
|
||||||
|
offsets=offsets,
|
||||||
|
mix_rate=mix_rate,
|
||||||
|
none_keys=none_keys,
|
||||||
|
)
|
||||||
|
|
||||||
async def pick_up_tips(
|
async def pick_up_tips(
|
||||||
self,
|
self,
|
||||||
@@ -187,7 +246,12 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
offsets: Optional[List[Coordinate]] = None,
|
offsets: Optional[List[Coordinate]] = None,
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
pass
|
return await super().pick_up_tips(
|
||||||
|
tip_spots=tip_spots,
|
||||||
|
use_channels=use_channels,
|
||||||
|
offsets=offsets,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
async def transfer_liquid(
|
async def transfer_liquid(
|
||||||
self,
|
self,
|
||||||
@@ -214,5 +278,26 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
delays: Optional[List[int]] = None,
|
delays: Optional[List[int]] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
):
|
):
|
||||||
pass
|
return await super().transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=tip_racks,
|
||||||
|
use_channels=use_channels,
|
||||||
|
asp_vols=asp_vols,
|
||||||
|
dis_vols=dis_vols,
|
||||||
|
asp_flow_rates=asp_flow_rates,
|
||||||
|
dis_flow_rates=dis_flow_rates,
|
||||||
|
offsets=offsets,
|
||||||
|
touch_tip=touch_tip,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
is_96_well=is_96_well,
|
||||||
|
mix_stage=mix_stage,
|
||||||
|
mix_times=mix_times,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
mix_rate=mix_rate,
|
||||||
|
mix_liquid_height=mix_liquid_height,
|
||||||
|
delays=delays,
|
||||||
|
none_keys=none_keys,
|
||||||
|
)
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ class TransferLiquidReturn(TypedDict):
|
|||||||
targets: List[List[ResourceDict]]
|
targets: List[List[ResourceDict]]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SetLiquidReturn(TypedDict):
|
||||||
|
wells: list
|
||||||
|
volumes: list
|
||||||
|
|
||||||
|
|
||||||
|
class SetLiquidFromPlateReturn(TypedDict):
|
||||||
|
plate: list
|
||||||
|
wells: list
|
||||||
|
volumes: list
|
||||||
|
|
||||||
|
|
||||||
class LiquidHandlerMiddleware(LiquidHandler):
|
class LiquidHandlerMiddleware(LiquidHandler):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs
|
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs
|
||||||
|
|||||||
376
unilabos/devices/motor/ZDT_X42.py
Normal file
376
unilabos/devices/motor/ZDT_X42.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
ZDT X42 Closed-Loop Stepper Motor Driver
|
||||||
|
RS485 Serial Communication via USB-Serial Converter
|
||||||
|
|
||||||
|
- Baudrate: 115200
|
||||||
|
"""
|
||||||
|
|
||||||
|
import serial
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import struct
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||||
|
except ImportError:
|
||||||
|
class UniversalDriver:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
def execute_command_from_outer(self, command: Any): pass
|
||||||
|
|
||||||
|
from serial.rs485 import RS485Settings
|
||||||
|
|
||||||
|
|
||||||
|
class ZDTX42Driver(UniversalDriver):
|
||||||
|
"""
|
||||||
|
ZDT X42 闭环步进电机驱动器
|
||||||
|
|
||||||
|
支持功能:
|
||||||
|
- 速度模式运行
|
||||||
|
- 位置模式运行 (相对/绝对)
|
||||||
|
- 位置读取和清零
|
||||||
|
- 使能/禁用控制
|
||||||
|
|
||||||
|
通信协议:
|
||||||
|
- 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B]
|
||||||
|
- 响应长度根据功能码决定
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
port: str,
|
||||||
|
baudrate: int = 115200,
|
||||||
|
device_id: int = 1,
|
||||||
|
timeout: float = 0.5,
|
||||||
|
debug: bool = False
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化 ZDT X42 电机驱动
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: 串口设备路径
|
||||||
|
baudrate: 波特率 (默认 115200)
|
||||||
|
device_id: 设备地址 (1-255)
|
||||||
|
timeout: 通信超时时间(秒)
|
||||||
|
debug: 是否启用调试输出
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.id = device_id
|
||||||
|
self.debug = debug
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.status = "idle" # 对应注册表中的 status (str)
|
||||||
|
self.position = 0 # 对应注册表中的 position (int)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.ser = serial.Serial(
|
||||||
|
port=port,
|
||||||
|
baudrate=baudrate,
|
||||||
|
timeout=timeout,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE
|
||||||
|
)
|
||||||
|
|
||||||
|
# 启用 RS485 模式
|
||||||
|
try:
|
||||||
|
self.ser.rs485_mode = RS485Settings(
|
||||||
|
rts_level_for_tx=True,
|
||||||
|
rts_level_for_rx=False
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # RS485 模式是可选的
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"ZDT X42 Motor connected: {port} "
|
||||||
|
f"(Baud: {baudrate}, ID: {device_id})"
|
||||||
|
)
|
||||||
|
# 自动使能电机,确保初始状态可运动
|
||||||
|
self.enable(True)
|
||||||
|
|
||||||
|
# 启动背景轮询线程,确保 position 实时刷新
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._polling_thread = threading.Thread(
|
||||||
|
target=self._update_loop,
|
||||||
|
name=f"ZDTPolling_{port}",
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._polling_thread.start()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to open serial port {port}: {e}")
|
||||||
|
self.ser = None
|
||||||
|
|
||||||
|
def _update_loop(self):
|
||||||
|
"""背景循环读取电机位置"""
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
self.get_position()
|
||||||
|
except Exception as e:
|
||||||
|
if self.debug:
|
||||||
|
self.logger.error(f"Polling error: {e}")
|
||||||
|
time.sleep(1.0) # 每1秒刷新一次位置数据
|
||||||
|
|
||||||
|
def _send(self, func_code: int, payload: list) -> bytes:
|
||||||
|
"""
|
||||||
|
发送指令并接收响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func_code: 功能码
|
||||||
|
payload: 数据负载 (list of bytes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据 (bytes)
|
||||||
|
"""
|
||||||
|
if not self.ser:
|
||||||
|
self.logger.error("Serial port not available")
|
||||||
|
return b""
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
# 清空输入缓冲区
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
|
||||||
|
# 构建消息: [ID] [功能码] [数据...] [校验位=0x6B]
|
||||||
|
message = bytes([self.id, func_code] + payload + [0x6B])
|
||||||
|
|
||||||
|
# 发送
|
||||||
|
self.ser.write(message)
|
||||||
|
|
||||||
|
# 根据功能码决定响应长度
|
||||||
|
# 查询类指令返回 10 字节,控制类指令返回 4 字节
|
||||||
|
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
|
||||||
|
response = self.ser.read(read_len)
|
||||||
|
|
||||||
|
# 调试输出
|
||||||
|
if self.debug:
|
||||||
|
sent_hex = message.hex().upper()
|
||||||
|
recv_hex = response.hex().upper() if response else 'TIMEOUT'
|
||||||
|
print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def enable(self, on: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
使能/禁用电机
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on: True=使能(锁轴), False=禁用(松轴)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
state = 1 if on else 0
|
||||||
|
resp = self._send(0xF3, [0xAB, state, 0])
|
||||||
|
return len(resp) >= 4
|
||||||
|
|
||||||
|
def move_speed(
|
||||||
|
self,
|
||||||
|
speed_rpm: int,
|
||||||
|
direction: str = "CW",
|
||||||
|
acceleration: int = 10
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
速度模式运行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
speed_rpm: 转速 (RPM)
|
||||||
|
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
|
||||||
|
acceleration: 加速度 (0-255)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
|
||||||
|
speed_bytes = struct.pack('>H', int(speed_rpm))
|
||||||
|
self.status = f"moving@{speed_rpm}rpm"
|
||||||
|
resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0])
|
||||||
|
return len(resp) >= 4
|
||||||
|
|
||||||
|
def move_position(
|
||||||
|
self,
|
||||||
|
pulses: int,
|
||||||
|
speed_rpm: int,
|
||||||
|
direction: str = "CW",
|
||||||
|
acceleration: int = 10,
|
||||||
|
absolute: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
位置模式运行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pulses: 脉冲数
|
||||||
|
speed_rpm: 转速 (RPM)
|
||||||
|
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
|
||||||
|
acceleration: 加速度 (0-255)
|
||||||
|
absolute: True=绝对位置, False=相对位置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
|
||||||
|
speed_bytes = struct.pack('>H', int(speed_rpm))
|
||||||
|
self.status = f"moving_to_{pulses}"
|
||||||
|
pulse_bytes = struct.pack('>I', int(pulses))
|
||||||
|
abs_flag = 1 if absolute else 0
|
||||||
|
|
||||||
|
payload = [
|
||||||
|
dir_val,
|
||||||
|
speed_bytes[0], speed_bytes[1],
|
||||||
|
acceleration,
|
||||||
|
pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3],
|
||||||
|
abs_flag,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = self._send(0xFD, payload)
|
||||||
|
return len(resp) >= 4
|
||||||
|
|
||||||
|
def stop(self) -> bool:
|
||||||
|
"""
|
||||||
|
停止电机
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
self.status = "idle"
|
||||||
|
resp = self._send(0xFE, [0x98, 0])
|
||||||
|
return len(resp) >= 4
|
||||||
|
|
||||||
|
def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool:
|
||||||
|
"""
|
||||||
|
电机旋转 1/4 圈 (阻塞式)
|
||||||
|
假设电机细分为 3200 脉冲/圈,1/4 圈 = 800 脉冲
|
||||||
|
"""
|
||||||
|
pulses = 800
|
||||||
|
success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60))
|
||||||
|
# 1/4 rev / (RPM/60) = 15.0 / RPM
|
||||||
|
estimated_time = 15.0 / max(1, speed_rpm)
|
||||||
|
time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲
|
||||||
|
self.status = "idle"
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def wait_time(self, duration_s: float) -> bool:
|
||||||
|
"""
|
||||||
|
等待指定时间 (秒)
|
||||||
|
"""
|
||||||
|
self.logger.info(f"Waiting for {duration_s} seconds...")
|
||||||
|
time.sleep(duration_s)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_zero(self) -> bool:
|
||||||
|
"""
|
||||||
|
清零当前位置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
resp = self._send(0x0A, [])
|
||||||
|
return len(resp) >= 4
|
||||||
|
|
||||||
|
def get_position(self) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
读取当前位置 (脉冲数)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
当前位置脉冲数,失败返回 None
|
||||||
|
"""
|
||||||
|
resp = self._send(0x32, [])
|
||||||
|
|
||||||
|
if len(resp) >= 8:
|
||||||
|
# 响应格式: [ID] [Func] [符号位] [数值4字节] [校验]
|
||||||
|
sign = resp[2] # 0=正, 1=负
|
||||||
|
value = struct.unpack('>I', resp[3:7])[0]
|
||||||
|
self.position = -value if sign == 1 else value
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}")
|
||||||
|
|
||||||
|
return self.position
|
||||||
|
|
||||||
|
self.logger.warning("Failed to read position")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭串口连接并停止线程"""
|
||||||
|
if hasattr(self, '_stop_event'):
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self.ser and self.ser.is_open:
|
||||||
|
self.ser.close()
|
||||||
|
self.logger.info("Serial port closed")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 测试和调试代码
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_motor():
|
||||||
|
"""基础功能测试"""
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
print("="*60)
|
||||||
|
print("ZDT X42 电机驱动测试")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
driver = ZDTX42Driver(
|
||||||
|
port="/dev/tty.usbserial-3110",
|
||||||
|
baudrate=115200,
|
||||||
|
device_id=2,
|
||||||
|
debug=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not driver.ser:
|
||||||
|
print("❌ 串口打开失败")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 测试 1: 读取位置
|
||||||
|
print("\n[1] 读取当前位置")
|
||||||
|
pos = driver.get_position()
|
||||||
|
print(f"✓ 当前位置: {pos} 脉冲")
|
||||||
|
|
||||||
|
# 测试 2: 使能
|
||||||
|
print("\n[2] 使能电机")
|
||||||
|
driver.enable(True)
|
||||||
|
time.sleep(0.3)
|
||||||
|
print("✓ 电机已锁定")
|
||||||
|
|
||||||
|
# 测试 3: 相对位置运动
|
||||||
|
print("\n[3] 相对位置运动 (1000脉冲)")
|
||||||
|
driver.move_position(pulses=1000, speed_rpm=60, direction="CW")
|
||||||
|
time.sleep(2)
|
||||||
|
pos = driver.get_position()
|
||||||
|
print(f"✓ 新位置: {pos}")
|
||||||
|
|
||||||
|
# 测试 4: 速度运动
|
||||||
|
print("\n[4] 速度模式 (30RPM, 3秒)")
|
||||||
|
driver.move_speed(speed_rpm=30, direction="CW")
|
||||||
|
time.sleep(3)
|
||||||
|
driver.stop()
|
||||||
|
pos = driver.get_position()
|
||||||
|
print(f"✓ 停止后位置: {pos}")
|
||||||
|
|
||||||
|
# 测试 5: 禁用
|
||||||
|
print("\n[5] 禁用电机")
|
||||||
|
driver.enable(False)
|
||||||
|
print("✓ 电机已松开")
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("✅ 测试完成")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 测试失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
driver.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_motor()
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"ip": "127.0.0.1",
|
"ip": "127.0.0.1",
|
||||||
"port": 502,
|
"port": 502,
|
||||||
"machine_ids": [1, 2, 3, 4, 5, 6, 86],
|
"machine_id": 1,
|
||||||
"devtype": "27",
|
"devtype": "27",
|
||||||
"timeout": 20,
|
"timeout": 20,
|
||||||
"size_x": 500.0,
|
"size_x": 500.0,
|
||||||
@@ -32,4 +32,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": []
|
"links": []
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -305,12 +305,11 @@ class NewareBatteryTestSystem:
|
|||||||
ascii_lowercase = 'abcdefghijklmnopqrstuvwxyz'
|
ascii_lowercase = 'abcdefghijklmnopqrstuvwxyz'
|
||||||
ascii_uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
ascii_uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
LETTERS = ascii_uppercase + ascii_lowercase
|
LETTERS = ascii_uppercase + ascii_lowercase
|
||||||
DEFAULT_MACHINE_IDS = [1, 2, 3, 4, 5, 6, 86]
|
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
ip: str = None,
|
ip: str = None,
|
||||||
port: int = None,
|
port: int = None,
|
||||||
machine_ids: Optional[List[int]] = None,
|
machine_id: int = 1,
|
||||||
devtype: str = None,
|
devtype: str = None,
|
||||||
timeout: int = None,
|
timeout: int = None,
|
||||||
|
|
||||||
@@ -327,18 +326,16 @@ class NewareBatteryTestSystem:
|
|||||||
Args:
|
Args:
|
||||||
ip: TCP服务器IP地址
|
ip: TCP服务器IP地址
|
||||||
port: TCP端口
|
port: TCP端口
|
||||||
machine_ids: 设备ID列表
|
|
||||||
devtype: 设备类型标识
|
devtype: 设备类型标识
|
||||||
timeout: 通信超时时间(秒)
|
timeout: 通信超时时间(秒)
|
||||||
|
machine_id: 机器ID
|
||||||
size_x, size_y, size_z: 设备物理尺寸
|
size_x, size_y, size_z: 设备物理尺寸
|
||||||
oss_upload_enabled: 是否启用OSS上传功能,默认False
|
oss_upload_enabled: 是否启用OSS上传功能,默认False
|
||||||
oss_prefix: OSS对象路径前缀,默认"neware_backup"
|
oss_prefix: OSS对象路径前缀,默认"neware_backup"
|
||||||
"""
|
"""
|
||||||
self.ip = ip or self.BTS_IP
|
self.ip = ip or self.BTS_IP
|
||||||
self.port = port or self.BTS_PORT
|
self.port = port or self.BTS_PORT
|
||||||
self.machine_ids = machine_ids
|
self.machine_id = machine_id
|
||||||
self.display_device_ids = self._resolve_display_device_ids()
|
|
||||||
self.primary_device_id = self.display_device_ids[0]
|
|
||||||
self.devtype = devtype or self.DEVTYPE
|
self.devtype = devtype or self.DEVTYPE
|
||||||
self.timeout = timeout or self.TIMEOUT
|
self.timeout = timeout or self.TIMEOUT
|
||||||
|
|
||||||
@@ -355,12 +352,6 @@ class NewareBatteryTestSystem:
|
|||||||
self._cached_status = {}
|
self._cached_status = {}
|
||||||
self._last_backup_dir = None # 记录最近一次的 backup_dir,供上传使用
|
self._last_backup_dir = None # 记录最近一次的 backup_dir,供上传使用
|
||||||
self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用,由框架设置
|
self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用,由框架设置
|
||||||
self._channels = self._build_channel_map()
|
|
||||||
|
|
||||||
def _resolve_display_device_ids(self) -> List[int]:
|
|
||||||
if self.machine_ids:
|
|
||||||
return [int(devid) for devid in self.machine_ids]
|
|
||||||
return self.DEFAULT_MACHINE_IDS.copy()
|
|
||||||
|
|
||||||
|
|
||||||
def post_init(self, ros_node):
|
def post_init(self, ros_node):
|
||||||
@@ -385,72 +376,27 @@ class NewareBatteryTestSystem:
|
|||||||
ros_node.lab_logger().error(f"新威电池测试系统初始化失败: {e}")
|
ros_node.lab_logger().error(f"新威电池测试系统初始化失败: {e}")
|
||||||
# 不抛出异常,允许节点继续运行,后续可以重试连接
|
# 不抛出异常,允许节点继续运行,后续可以重试连接
|
||||||
|
|
||||||
def _plate_name(self, devid: int, plate_num: int) -> str:
|
|
||||||
return f"{devid}_P{plate_num}"
|
|
||||||
|
|
||||||
def _plate_resource_key(self, devid: int, plate_num: int, row_idx: int, col_idx: int) -> str:
|
|
||||||
return f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}"
|
|
||||||
|
|
||||||
def _get_plate_resource(self, devid: int, plate_num: int, row_idx: int, col_idx: int):
|
|
||||||
possible_names = [
|
|
||||||
f"{self._plate_name(devid, plate_num)}_batterytestposition_{col_idx}_{row_idx}",
|
|
||||||
f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}",
|
|
||||||
f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx].lower()}{col_idx + 1}",
|
|
||||||
f"P{plate_num}_batterytestposition_{col_idx}_{row_idx}",
|
|
||||||
f"P{plate_num}_{self.LETTERS[row_idx]}{col_idx + 1}",
|
|
||||||
f"P{plate_num}_{self.LETTERS[row_idx].lower()}{col_idx + 1}",
|
|
||||||
]
|
|
||||||
for name in possible_names:
|
|
||||||
if name in self.station_resources:
|
|
||||||
return self.station_resources[name], name, possible_names
|
|
||||||
return None, None, possible_names
|
|
||||||
|
|
||||||
def _setup_material_management(self):
|
def _setup_material_management(self):
|
||||||
"""设置物料管理系统"""
|
"""设置物料管理系统"""
|
||||||
deck_main = Deck(
|
# 第1盘:5行8列网格 (A1-E8) - 5行对应subdevid 1-5,8列对应chlid 1-8
|
||||||
name="ADeckName",
|
# 先给物料设置一个最大的Deck,并设置其在空间中的位置
|
||||||
size_x=2200,
|
|
||||||
size_y=2800,
|
deck_main = Deck("ADeckName", 2000, 1800, 100, origin=Coordinate(2000,2000,0))
|
||||||
size_z=100,
|
|
||||||
origin=Coordinate(2000, 2000, 0)
|
plate1_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d(
|
||||||
|
BatteryTestPosition,
|
||||||
|
num_items_x=8, # 8列(对应chlid 1-8)
|
||||||
|
num_items_y=5, # 5行(对应subdevid 1-5,即A-E)
|
||||||
|
dx=10,
|
||||||
|
dy=10,
|
||||||
|
dz=0,
|
||||||
|
item_dx=65,
|
||||||
|
item_dy=65
|
||||||
)
|
)
|
||||||
self.station_resources = {}
|
plate1 = Plate("P1", 400, 300, 50, ordered_items=plate1_resources)
|
||||||
self.station_resources_by_plate = {}
|
deck_main.assign_child_resource(plate1, location=Coordinate(0, 0, 0))
|
||||||
|
|
||||||
for row_idx, devid in enumerate(self.display_device_ids):
|
# 只有在真实ROS环境下才调用update_resource
|
||||||
for plate_num in (1, 2):
|
|
||||||
plate_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d(
|
|
||||||
BatteryTestPosition,
|
|
||||||
num_items_x=8,
|
|
||||||
num_items_y=5,
|
|
||||||
dx=10,
|
|
||||||
dy=10,
|
|
||||||
dz=0,
|
|
||||||
item_dx=65,
|
|
||||||
item_dy=65
|
|
||||||
)
|
|
||||||
plate_name = self._plate_name(devid, plate_num)
|
|
||||||
plate = Plate(
|
|
||||||
name=plate_name,
|
|
||||||
size_x=400,
|
|
||||||
size_y=300,
|
|
||||||
size_z=50,
|
|
||||||
ordered_items=plate_resources
|
|
||||||
)
|
|
||||||
location_x = 0 if plate_num == 1 else 450
|
|
||||||
location_y = row_idx * 350
|
|
||||||
deck_main.assign_child_resource(plate, location=Coordinate(location_x, location_y, 0))
|
|
||||||
|
|
||||||
plate_key = (devid, plate_num)
|
|
||||||
self.station_resources_by_plate[plate_key] = {}
|
|
||||||
for name, resource in plate_resources.items():
|
|
||||||
new_name = f"{plate_name}_{name}"
|
|
||||||
self.station_resources_by_plate[plate_key][new_name] = resource
|
|
||||||
self.station_resources[new_name] = resource
|
|
||||||
|
|
||||||
self.station_resources_plate1 = self.station_resources_by_plate.get((self.primary_device_id, 1), {})
|
|
||||||
self.station_resources_plate2 = self.station_resources_by_plate.get((self.primary_device_id, 2), {})
|
|
||||||
|
|
||||||
if hasattr(self._ros_node, 'update_resource') and callable(getattr(self._ros_node, 'update_resource')):
|
if hasattr(self._ros_node, 'update_resource') and callable(getattr(self._ros_node, 'update_resource')):
|
||||||
try:
|
try:
|
||||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||||
@@ -459,6 +405,40 @@ class NewareBatteryTestSystem:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if hasattr(self._ros_node, 'lab_logger'):
|
if hasattr(self._ros_node, 'lab_logger'):
|
||||||
self._ros_node.lab_logger().warning(f"更新资源失败: {e}")
|
self._ros_node.lab_logger().warning(f"更新资源失败: {e}")
|
||||||
|
# 在非ROS环境下忽略此错误
|
||||||
|
|
||||||
|
# 为第1盘资源添加P1_前缀
|
||||||
|
self.station_resources_plate1 = {}
|
||||||
|
for name, resource in plate1_resources.items():
|
||||||
|
new_name = f"P1_{name}"
|
||||||
|
self.station_resources_plate1[new_name] = resource
|
||||||
|
|
||||||
|
# 第2盘:5行8列网格 (A1-E8),在Z轴上偏移 - 5行对应subdevid 6-10,8列对应chlid 1-8
|
||||||
|
plate2_resources = create_ordered_items_2d(
|
||||||
|
BatteryTestPosition,
|
||||||
|
num_items_x=8, # 8列(对应chlid 1-8)
|
||||||
|
num_items_y=5, # 5行(对应subdevid 6-10,即A-E)
|
||||||
|
dx=10,
|
||||||
|
dy=10,
|
||||||
|
dz=0,
|
||||||
|
item_dx=65,
|
||||||
|
item_dy=65
|
||||||
|
)
|
||||||
|
|
||||||
|
plate2 = Plate("P2", 400, 300, 50, ordered_items=plate2_resources)
|
||||||
|
deck_main.assign_child_resource(plate2, location=Coordinate(0, 350, 0))
|
||||||
|
|
||||||
|
|
||||||
|
# 为第2盘资源添加P2_前缀
|
||||||
|
self.station_resources_plate2 = {}
|
||||||
|
for name, resource in plate2_resources.items():
|
||||||
|
new_name = f"P2_{name}"
|
||||||
|
self.station_resources_plate2[new_name] = resource
|
||||||
|
|
||||||
|
# 合并两盘资源为统一的station_resources
|
||||||
|
self.station_resources = {}
|
||||||
|
self.station_resources.update(self.station_resources_plate1)
|
||||||
|
self.station_resources.update(self.station_resources_plate2)
|
||||||
|
|
||||||
# ========================
|
# ========================
|
||||||
# 核心属性(Uni-Lab标准)
|
# 核心属性(Uni-Lab标准)
|
||||||
@@ -489,16 +469,16 @@ class NewareBatteryTestSystem:
|
|||||||
status_map = self._query_all_channels()
|
status_map = self._query_all_channels()
|
||||||
status_processed = {} if not status_map else self._group_by_devid(status_map)
|
status_processed = {} if not status_map else self._group_by_devid(status_map)
|
||||||
|
|
||||||
# 返回主设备数据,如果主设备没有匹配数据则回退到首个可用设备
|
# 修复数据过滤逻辑:如果machine_id对应的数据不存在,尝试使用第一个可用的设备数据
|
||||||
status_current_machine = status_processed.get(self.primary_device_id, {})
|
status_current_machine = status_processed.get(self.machine_id, {})
|
||||||
|
|
||||||
if not status_current_machine and status_processed:
|
if not status_current_machine and status_processed:
|
||||||
# 如果主设备没有匹配到数据,使用第一个可用的设备数据
|
# 如果machine_id没有匹配到数据,使用第一个可用的设备数据
|
||||||
first_devid = next(iter(status_processed.keys()))
|
first_devid = next(iter(status_processed.keys()))
|
||||||
status_current_machine = status_processed[first_devid]
|
status_current_machine = status_processed[first_devid]
|
||||||
if self._ros_node:
|
if self._ros_node:
|
||||||
self._ros_node.lab_logger().warning(
|
self._ros_node.lab_logger().warning(
|
||||||
f"主设备ID {self.primary_device_id} 没有匹配到数据,使用设备ID {first_devid} 的数据"
|
f"machine_id {self.machine_id} 没有匹配到数据,使用设备ID {first_devid} 的数据"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 确保有默认的数据结构
|
# 确保有默认的数据结构
|
||||||
@@ -508,57 +488,139 @@ class NewareBatteryTestSystem:
|
|||||||
"subunits": {}
|
"subunits": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
self._update_plate_resources(status_processed)
|
# 确保subunits存在
|
||||||
|
subunits = status_current_machine.get("subunits", {})
|
||||||
|
|
||||||
|
# 处理2盘电池的状态映射
|
||||||
|
self._update_plate_resources(subunits)
|
||||||
|
|
||||||
return status_current_machine
|
return status_current_machine
|
||||||
|
|
||||||
def _update_plate_resources(self, status_processed: Dict[int, Dict]):
|
def _update_plate_resources(self, subunits: Dict):
|
||||||
"""更新7台设备共14盘电池资源的状态"""
|
"""更新两盘电池资源的状态"""
|
||||||
for devid in self.display_device_ids:
|
# 第1盘:subdevid 1-5 映射到 8列5行网格 (列0-7, 行0-4)
|
||||||
machine_data = status_processed.get(devid, {})
|
for subdev_id in range(1, 6): # subdevid 1-5
|
||||||
subunits = machine_data.get("subunits", {})
|
status_row = subunits.get(subdev_id, {})
|
||||||
for plate_num, subdev_start, subdev_end in ((1, 1, 5), (2, 6, 10)):
|
|
||||||
for subdev_id in range(subdev_start, subdev_end + 1):
|
for chl_id in range(1, 9): # chlid 1-8
|
||||||
status_row = subunits.get(subdev_id, {})
|
try:
|
||||||
for chl_id in range(1, 9):
|
# 根据用户描述:第一个是(0,0),最后一个是(7,4)
|
||||||
try:
|
# 说明是8列5行,列从0开始,行从0开始
|
||||||
col_idx = chl_id - 1
|
col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7)
|
||||||
row_idx = subdev_id - subdev_start
|
row_idx = (subdev_id - 1) # 0-4 (subdevid 1-5 -> 行0-4)
|
||||||
r, resource_name, possible_names = self._get_plate_resource(
|
|
||||||
devid=devid,
|
# 尝试多种可能的资源命名格式
|
||||||
plate_num=plate_num,
|
possible_names = [
|
||||||
row_idx=row_idx,
|
f"P1_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式
|
||||||
col_idx=col_idx
|
f"P1_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式
|
||||||
|
f"P1_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式
|
||||||
|
]
|
||||||
|
|
||||||
|
r = None
|
||||||
|
resource_name = None
|
||||||
|
for name in possible_names:
|
||||||
|
if name in self.station_resources:
|
||||||
|
r = self.station_resources[name]
|
||||||
|
resource_name = name
|
||||||
|
break
|
||||||
|
|
||||||
|
if r:
|
||||||
|
status_channel = status_row.get(chl_id, {})
|
||||||
|
metrics = status_channel.get("metrics", {})
|
||||||
|
# 构建BatteryTestPosition状态数据(移除capacity和energy)
|
||||||
|
channel_state = {
|
||||||
|
# 基本测量数据
|
||||||
|
"voltage": metrics.get("voltage_V", 0.0),
|
||||||
|
"current": metrics.get("current_A", 0.0),
|
||||||
|
"time": metrics.get("totaltime_s", 0.0),
|
||||||
|
|
||||||
|
# 状态信息
|
||||||
|
"status": status_channel.get("state", "unknown"),
|
||||||
|
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
|
||||||
|
|
||||||
|
# 通道名称标识
|
||||||
|
"Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}",
|
||||||
|
|
||||||
|
}
|
||||||
|
r.load_state(channel_state)
|
||||||
|
|
||||||
|
# 调试信息
|
||||||
|
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||||
|
self._ros_node.lab_logger().debug(
|
||||||
|
f"更新P1资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} "
|
||||||
|
f"状态:{channel_state['status']}"
|
||||||
)
|
)
|
||||||
if r is None:
|
else:
|
||||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
# 如果找不到资源,记录调试信息
|
||||||
self._ros_node.lab_logger().debug(
|
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||||
f"{devid}_P{plate_num}未找到资源: subdev{subdev_id}/chl{chl_id} -> "
|
self._ros_node.lab_logger().debug(
|
||||||
f"尝试的名称: {possible_names}"
|
f"P1未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}"
|
||||||
)
|
)
|
||||||
continue
|
except (KeyError, IndexError) as e:
|
||||||
status_channel = status_row.get(chl_id, {})
|
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||||
metrics = status_channel.get("metrics", {})
|
self._ros_node.lab_logger().debug(f"P1映射错误: subdev{subdev_id}/chl{chl_id} - {e}")
|
||||||
channel_state = {
|
continue
|
||||||
"voltage": metrics.get("voltage_V", 0.0),
|
|
||||||
"current": metrics.get("current_A", 0.0),
|
# 第2盘:subdevid 6-10 映射到 8列5行网格 (列0-7, 行0-4)
|
||||||
"time": metrics.get("totaltime_s", 0.0),
|
for subdev_id in range(6, 11): # subdevid 6-10
|
||||||
"status": status_channel.get("state", "unknown"),
|
status_row = subunits.get(subdev_id, {})
|
||||||
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
|
|
||||||
"Channel_Name": f"{devid}-{subdev_id}-{chl_id}",
|
for chl_id in range(1, 9): # chlid 1-8
|
||||||
}
|
try:
|
||||||
r.load_state(channel_state)
|
col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7)
|
||||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
row_idx = (subdev_id - 6) # 0-4 (subdevid 6-10 -> 行0-4)
|
||||||
self._ros_node.lab_logger().debug(
|
|
||||||
f"更新{devid}_P{plate_num}资源状态: {resource_name} <- "
|
# 尝试多种可能的资源命名格式
|
||||||
f"subdev{subdev_id}/chl{chl_id} 状态:{channel_state['status']}"
|
possible_names = [
|
||||||
)
|
f"P2_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式
|
||||||
except (KeyError, IndexError) as e:
|
f"P2_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式
|
||||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
f"P2_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式
|
||||||
self._ros_node.lab_logger().debug(
|
]
|
||||||
f"{devid}_P{plate_num}映射错误: subdev{subdev_id}/chl{chl_id} - {e}"
|
|
||||||
)
|
r = None
|
||||||
continue
|
resource_name = None
|
||||||
|
for name in possible_names:
|
||||||
|
if name in self.station_resources:
|
||||||
|
r = self.station_resources[name]
|
||||||
|
resource_name = name
|
||||||
|
break
|
||||||
|
|
||||||
|
if r:
|
||||||
|
status_channel = status_row.get(chl_id, {})
|
||||||
|
metrics = status_channel.get("metrics", {})
|
||||||
|
# 构建BatteryTestPosition状态数据(移除capacity和energy)
|
||||||
|
channel_state = {
|
||||||
|
# 基本测量数据
|
||||||
|
"voltage": metrics.get("voltage_V", 0.0),
|
||||||
|
"current": metrics.get("current_A", 0.0),
|
||||||
|
"time": metrics.get("totaltime_s", 0.0),
|
||||||
|
|
||||||
|
# 状态信息
|
||||||
|
"status": status_channel.get("state", "unknown"),
|
||||||
|
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
|
||||||
|
|
||||||
|
# 通道名称标识
|
||||||
|
"Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}",
|
||||||
|
|
||||||
|
}
|
||||||
|
r.load_state(channel_state)
|
||||||
|
|
||||||
|
# 调试信息
|
||||||
|
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||||
|
self._ros_node.lab_logger().debug(
|
||||||
|
f"更新P2资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} "
|
||||||
|
f"状态:{channel_state['status']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 如果找不到资源,记录调试信息
|
||||||
|
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||||
|
self._ros_node.lab_logger().debug(
|
||||||
|
f"P2未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}"
|
||||||
|
)
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||||
|
self._ros_node.lab_logger().debug(f"P2映射错误: subdev{subdev_id}/chl{chl_id} - {e}")
|
||||||
|
continue
|
||||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||||
"resources": list(self.station_resources.values())
|
"resources": list(self.station_resources.values())
|
||||||
})
|
})
|
||||||
@@ -578,22 +640,6 @@ class NewareBatteryTestSystem:
|
|||||||
"""获取总通道数"""
|
"""获取总通道数"""
|
||||||
return len(self._channels)
|
return len(self._channels)
|
||||||
|
|
||||||
def _build_device_summary_dict(self) -> dict:
|
|
||||||
if not hasattr(self, '_channels') or not self._channels:
|
|
||||||
self._channels = self._build_channel_map()
|
|
||||||
channel_count_by_devid = {}
|
|
||||||
for channel in self._channels:
|
|
||||||
devid = channel.devid
|
|
||||||
channel_count_by_devid[devid] = channel_count_by_devid.get(devid, 0) + 1
|
|
||||||
return {
|
|
||||||
"channel_count_by_devid": channel_count_by_devid,
|
|
||||||
"display_device_ids": self.display_device_ids,
|
|
||||||
"total_channels": len(self._channels)
|
|
||||||
}
|
|
||||||
|
|
||||||
def device_summary(self) -> str:
|
|
||||||
return json.dumps(self._build_device_summary_dict(), ensure_ascii=False)
|
|
||||||
|
|
||||||
# ========================
|
# ========================
|
||||||
# 设备动作方法(Uni-Lab标准)
|
# 设备动作方法(Uni-Lab标准)
|
||||||
# ========================
|
# ========================
|
||||||
@@ -918,7 +964,6 @@ class NewareBatteryTestSystem:
|
|||||||
'SIGR_LI': gen_mod.xml_SiGr_Li_Step,
|
'SIGR_LI': gen_mod.xml_SiGr_Li_Step,
|
||||||
'811_SIGR': gen_mod.xml_811_SiGr,
|
'811_SIGR': gen_mod.xml_811_SiGr,
|
||||||
'811_CU_AGING': gen_mod.xml_811_Cu_aging,
|
'811_CU_AGING': gen_mod.xml_811_Cu_aging,
|
||||||
'ZQXNLRMO':gen_mod.xml_ZQXNLRMO,
|
|
||||||
}
|
}
|
||||||
if key not in fmap:
|
if key not in fmap:
|
||||||
raise ValueError(f"未定义电池体系映射: {key}")
|
raise ValueError(f"未定义电池体系映射: {key}")
|
||||||
@@ -1096,7 +1141,16 @@ class NewareBatteryTestSystem:
|
|||||||
dict: ROS2动作结果格式 {"return_info": str, "success": bool}
|
dict: ROS2动作结果格式 {"return_info": str, "success": bool}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result_info = self.device_summary()
|
# 确保_channels已初始化
|
||||||
|
if not hasattr(self, '_channels') or not self._channels:
|
||||||
|
self._channels = self._build_channel_map()
|
||||||
|
|
||||||
|
summary = {}
|
||||||
|
for channel in self._channels:
|
||||||
|
devid = channel.devid
|
||||||
|
summary[devid] = summary.get(devid, 0) + 1
|
||||||
|
|
||||||
|
result_info = json.dumps(summary, ensure_ascii=False)
|
||||||
success_msg = f"设备摘要统计: {result_info}"
|
success_msg = f"设备摘要统计: {result_info}"
|
||||||
if self._ros_node:
|
if self._ros_node:
|
||||||
self._ros_node.lab_logger().info(success_msg)
|
self._ros_node.lab_logger().info(success_msg)
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import socket
|
|
||||||
END_MARKS = [b"\r\n#\r\n", b"</bts>"] # 读到任一标志即可判定完整响应
|
|
||||||
|
|
||||||
def build_start_command(devid, subdevid, chlid, CoinID,
|
|
||||||
ip_in_xml="127.0.0.1",
|
|
||||||
devtype:int=27,
|
|
||||||
recipe_path:str=f"D:\\HHM_test\\A001.xml",
|
|
||||||
backup_dir:str=f"D:\\HHM_test\\backup") -> str:
|
|
||||||
lines = [
|
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
||||||
'<bts version="1.0">',
|
|
||||||
' <cmd>start</cmd>',
|
|
||||||
' <list count="1">',
|
|
||||||
f' <start ip="{ip_in_xml}" devtype="{devtype}" devid="{devid}" subdevid="{subdevid}" chlid="{chlid}" barcode="{CoinID}">{recipe_path}</start>',
|
|
||||||
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="0" backupontime="1" backupontimeinterval="1" backupfree="0" />',
|
|
||||||
' </list>',
|
|
||||||
'</bts>',
|
|
||||||
]
|
|
||||||
# TCP 模式:请求必须以 #\r\n 结束(协议要求)
|
|
||||||
return "\r\n".join(lines) + "\r\n#\r\n"
|
|
||||||
|
|
||||||
def recv_until_marks(sock: socket.socket, timeout=60):
|
|
||||||
sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2}
|
|
||||||
buf = bytearray()
|
|
||||||
while True:
|
|
||||||
chunk = sock.recv(8192)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
buf += chunk
|
|
||||||
# 读到结束标志就停,避免等对端断开
|
|
||||||
for m in END_MARKS:
|
|
||||||
if m in buf:
|
|
||||||
return bytes(buf)
|
|
||||||
# 保险:读到完整 XML 结束标签也停
|
|
||||||
if b"</bts>" in buf:
|
|
||||||
return bytes(buf)
|
|
||||||
return bytes(buf)
|
|
||||||
|
|
||||||
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup"):
|
|
||||||
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir)
|
|
||||||
#print(xml_cmd)
|
|
||||||
with socket.create_connection((ip, port), timeout=60) as s:
|
|
||||||
s.sendall(xml_cmd.encode("utf-8"))
|
|
||||||
data = recv_until_marks(s, timeout=60)
|
|
||||||
return data.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup")
|
|
||||||
print(resp)
|
|
||||||
@@ -623,6 +623,119 @@ class ChinweDevice(UniversalDriver):
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700,
|
||||||
|
max_cycles: int = 0, timeout: int = 300) -> bool:
|
||||||
|
"""
|
||||||
|
分液步骤 - 液位传感器与电机联动
|
||||||
|
当液位传感器检测到"有液"时,电机顺时针旋转指定脉冲数
|
||||||
|
当液位传感器检测到"无液"时,电机逆时针旋转指定脉冲数
|
||||||
|
|
||||||
|
:param motor_id: 电机ID (必须在初始化时配置的motor_ids中)
|
||||||
|
:param speed: 电机转速 (RPM)
|
||||||
|
:param pulses: 每次旋转的脉冲数 (默认700约为1/4圈,假设3200脉冲/圈)
|
||||||
|
:param max_cycles: 最大执行循环次数 (0=无限制,默认0)
|
||||||
|
:param timeout: 整体超时时间 (秒)
|
||||||
|
:return: 成功返回True,超时或失败返回False
|
||||||
|
"""
|
||||||
|
motor_id = int(motor_id)
|
||||||
|
speed = int(speed)
|
||||||
|
pulses = int(pulses)
|
||||||
|
max_cycles = int(max_cycles)
|
||||||
|
timeout = int(timeout)
|
||||||
|
|
||||||
|
# 检查电机是否存在
|
||||||
|
if motor_id not in self.motors:
|
||||||
|
self.logger.error(f"Motor {motor_id} not found in configured motors: {list(self.motors.keys())}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查传感器是否可用
|
||||||
|
if not self.sensor:
|
||||||
|
self.logger.error("Sensor not initialized")
|
||||||
|
return False
|
||||||
|
|
||||||
|
motor = self.motors[motor_id]
|
||||||
|
|
||||||
|
# 停止轮询线程,避免与 separation_step 同时读取传感器造成串口冲突
|
||||||
|
self.logger.info("Stopping polling thread for separation_step...")
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._poll_thread and self._poll_thread.is_alive():
|
||||||
|
self._poll_thread.join(timeout=2.0)
|
||||||
|
|
||||||
|
# 使能电机
|
||||||
|
self.logger.info(f"Enabling motor {motor_id}...")
|
||||||
|
motor.enable(True)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
self.logger.info(f"Starting separation step: motor_id={motor_id}, speed={speed} RPM, "
|
||||||
|
f"pulses={pulses}, max_cycles={max_cycles}, timeout={timeout}s")
|
||||||
|
|
||||||
|
# 记录上一次的液位状态
|
||||||
|
last_level = None
|
||||||
|
cycle_count = 0
|
||||||
|
start_time = time.time()
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# 检查超时
|
||||||
|
if time.time() - start_time > timeout:
|
||||||
|
self.logger.warning(f"Separation step timeout after {timeout} seconds")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查循环次数限制
|
||||||
|
if max_cycles > 0 and cycle_count >= max_cycles:
|
||||||
|
self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 读取传感器数据
|
||||||
|
data = self.sensor.read_level()
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
error_count += 1
|
||||||
|
if error_count > 5:
|
||||||
|
self.logger.warning("Sensor read failed multiple times, retrying...")
|
||||||
|
error_count = 0
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
error_count = 0
|
||||||
|
current_level = data['level']
|
||||||
|
rssi = data['rssi']
|
||||||
|
|
||||||
|
# 检测状态变化 (包括首次检测)
|
||||||
|
if current_level != last_level:
|
||||||
|
cycle_count += 1
|
||||||
|
|
||||||
|
if current_level:
|
||||||
|
# 有液 -> 电机顺时针旋转
|
||||||
|
self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), "
|
||||||
|
f"rotating motor {motor_id} clockwise {pulses} pulses")
|
||||||
|
motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False)
|
||||||
|
|
||||||
|
# 等待电机完成 (预估时间)
|
||||||
|
estimated_time = 15.0 / max(1, speed)
|
||||||
|
time.sleep(estimated_time + 0.5)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 无液 -> 电机逆时针旋转
|
||||||
|
self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), "
|
||||||
|
f"rotating motor {motor_id} counter-clockwise {pulses} pulses")
|
||||||
|
motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False)
|
||||||
|
|
||||||
|
# 等待电机完成 (预估时间)
|
||||||
|
estimated_time = 15.0 / max(1, speed)
|
||||||
|
time.sleep(estimated_time + 0.5)
|
||||||
|
|
||||||
|
# 更新状态
|
||||||
|
last_level = current_level
|
||||||
|
|
||||||
|
# 轮询间隔
|
||||||
|
time.sleep(0.1)
|
||||||
|
finally:
|
||||||
|
# 恢复轮询线程
|
||||||
|
self.logger.info("Restarting polling thread...")
|
||||||
|
self._start_polling()
|
||||||
|
|
||||||
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
|
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
|
||||||
"""支持标准 JSON 指令调用"""
|
"""支持标准 JSON 指令调用"""
|
||||||
return super().execute_command_from_outer(command_dict)
|
return super().execute_command_from_outer(command_dict)
|
||||||
|
|||||||
379
unilabos/devices/separator/xkc_sensor.py
Normal file
379
unilabos/devices/separator/xkc_sensor.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
XKC RS485 液位传感器 (Modbus RTU)
|
||||||
|
|
||||||
|
说明:
|
||||||
|
1. 遵循 Modbus-RTU 协议。
|
||||||
|
2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。
|
||||||
|
3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。
|
||||||
|
4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import serial
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||||
|
|
||||||
|
class TransportManager:
|
||||||
|
"""
|
||||||
|
统一通信管理类。
|
||||||
|
仅支持 串口 (Serial/有线) 连接。
|
||||||
|
"""
|
||||||
|
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.timeout = timeout
|
||||||
|
self.logger = logger
|
||||||
|
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
|
||||||
|
|
||||||
|
self.serial = None
|
||||||
|
self._connect_serial()
|
||||||
|
|
||||||
|
def _connect_serial(self):
|
||||||
|
try:
|
||||||
|
self.serial = serial.Serial(
|
||||||
|
port=self.port,
|
||||||
|
baudrate=self.baudrate,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise ConnectionError(f"Serial open failed: {e}")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭连接"""
|
||||||
|
if self.serial and self.serial.is_open:
|
||||||
|
self.serial.close()
|
||||||
|
|
||||||
|
def clear_buffer(self):
|
||||||
|
"""清空缓冲区 (Thread-safe)"""
|
||||||
|
with self.lock:
|
||||||
|
if self.serial:
|
||||||
|
self.serial.reset_input_buffer()
|
||||||
|
|
||||||
|
def write(self, data: bytes):
|
||||||
|
"""发送原始字节"""
|
||||||
|
with self.lock:
|
||||||
|
if self.serial:
|
||||||
|
self.serial.write(data)
|
||||||
|
|
||||||
|
def read(self, size: int) -> bytes:
|
||||||
|
"""读取指定长度字节"""
|
||||||
|
if self.serial:
|
||||||
|
return self.serial.read(size)
|
||||||
|
return b''
|
||||||
|
|
||||||
|
class XKCSensorDriver(UniversalDriver):
|
||||||
|
"""XKC RS485 液位传感器 (Modbus RTU)"""
|
||||||
|
|
||||||
|
def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6,
|
||||||
|
threshold: int = 300, timeout: float = 3.0, debug: bool = False):
|
||||||
|
super().__init__()
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.device_id = device_id
|
||||||
|
self.threshold = threshold
|
||||||
|
self.timeout = timeout
|
||||||
|
self.debug = debug
|
||||||
|
self.level = False
|
||||||
|
self.rssi = 0
|
||||||
|
self.status = {"level": self.level, "rssi": self.rssi}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.transport = TransportManager(port, baudrate, timeout, logger=self.logger)
|
||||||
|
self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to connect XKCSensorDriver: {e}")
|
||||||
|
self.transport = None
|
||||||
|
|
||||||
|
# 启动背景轮询线程,确保 status 实时刷新
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._polling_thread = threading.Thread(
|
||||||
|
target=self._update_loop,
|
||||||
|
name=f"XKCPolling_{port}",
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
if self.transport:
|
||||||
|
self._polling_thread.start()
|
||||||
|
|
||||||
|
def _update_loop(self):
|
||||||
|
"""背景循环读取传感器数据"""
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
self.read_level()
|
||||||
|
except Exception as e:
|
||||||
|
if self.debug:
|
||||||
|
self.logger.error(f"Polling error: {e}")
|
||||||
|
time.sleep(2.0) # 每2秒刷新一次数据
|
||||||
|
|
||||||
|
def _crc(self, data: bytes) -> bytes:
|
||||||
|
crc = 0xFFFF
|
||||||
|
for byte in data:
|
||||||
|
crc ^= byte
|
||||||
|
for _ in range(8):
|
||||||
|
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
|
||||||
|
else: crc >>= 1
|
||||||
|
return struct.pack('<H', crc)
|
||||||
|
|
||||||
|
def read_level(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
读取液位。
|
||||||
|
返回: {'level': bool, 'rssi': int}
|
||||||
|
"""
|
||||||
|
if not self.transport:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with self.transport.lock:
|
||||||
|
self.transport.clear_buffer()
|
||||||
|
# Modbus Read Registers: 01 03 00 01 00 02 CRC
|
||||||
|
payload = struct.pack('>HH', 0x0001, 0x0002)
|
||||||
|
msg = struct.pack('BB', self.device_id, 0x03) + payload
|
||||||
|
msg += self._crc(msg)
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}")
|
||||||
|
|
||||||
|
self.transport.write(msg)
|
||||||
|
|
||||||
|
# Read header
|
||||||
|
h = self.transport.read(3) # Addr, Func, Len
|
||||||
|
if self.debug:
|
||||||
|
self.logger.info(f"RX Header: {h.hex().upper()}")
|
||||||
|
|
||||||
|
if len(h) < 3: return None
|
||||||
|
length = h[2]
|
||||||
|
|
||||||
|
# Read body + CRC
|
||||||
|
body = self.transport.read(length + 2)
|
||||||
|
if self.debug:
|
||||||
|
self.logger.info(f"RX Body+CRC: {body.hex().upper()}")
|
||||||
|
if len(body) < length + 2:
|
||||||
|
# Firmware bug fix specific to some modules
|
||||||
|
if len(body) == 4 and length == 4:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = body[:-2]
|
||||||
|
# 根据手册说明:
|
||||||
|
# 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液)
|
||||||
|
# 寄存器 0x0002 (data[2:4]): 信号强度 RSSI
|
||||||
|
|
||||||
|
hw_level = False
|
||||||
|
rssi = 0
|
||||||
|
|
||||||
|
if len(data) >= 4:
|
||||||
|
hw_level = ((data[0] << 8) | data[1]) == 1
|
||||||
|
rssi = (data[2] << 8) | data[3]
|
||||||
|
elif len(data) == 2:
|
||||||
|
# 兼容模式: 某些老固件可能只返回 1 个寄存器
|
||||||
|
rssi = (data[0] << 8) | data[1]
|
||||||
|
hw_level = rssi > self.threshold
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验
|
||||||
|
# 注意: 如果用户显式设置了 THRESHOLD,我们可以在逻辑中做权衡
|
||||||
|
self.level = hw_level or (rssi > self.threshold)
|
||||||
|
self.rssi = rssi
|
||||||
|
result = {
|
||||||
|
'level': self.level,
|
||||||
|
'rssi': self.rssi
|
||||||
|
}
|
||||||
|
self.status = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool:
|
||||||
|
"""
|
||||||
|
等待液位达到目标状态 (阻塞式)
|
||||||
|
"""
|
||||||
|
self.logger.info(f"Waiting for level: {target_state}")
|
||||||
|
start_time = time.time()
|
||||||
|
while (time.time() - start_time) < timeout:
|
||||||
|
res = self.read_level()
|
||||||
|
if res and res.get('level') == target_state:
|
||||||
|
return True
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.logger.warning(f"Wait level timeout ({timeout}s)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool:
|
||||||
|
"""
|
||||||
|
实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。
|
||||||
|
一旦检测到符合目标状态,立即返回。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_state: True 为“有液”, False 为“无液”
|
||||||
|
timeout: 最大等待时间(秒)
|
||||||
|
"""
|
||||||
|
state_str = "有液" if target_state else "无液"
|
||||||
|
self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
while (time.time() - start_time) < timeout:
|
||||||
|
res = self.read_level() # 内部已更新 self.level 和 self.rssi
|
||||||
|
if res:
|
||||||
|
current_level = res.get('level')
|
||||||
|
current_rssi = res.get('rssi')
|
||||||
|
if current_level == target_state:
|
||||||
|
self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}")
|
||||||
|
|
||||||
|
time.sleep(0.2) # 高频采样
|
||||||
|
|
||||||
|
self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_threshold(self, threshold: int):
|
||||||
|
"""设置液位判定阈值"""
|
||||||
|
self.threshold = int(threshold)
|
||||||
|
self.logger.info(f"Threshold updated to: {self.threshold}")
|
||||||
|
|
||||||
|
def change_device_id(self, new_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
修改设备的 Modbus 从站地址。
|
||||||
|
寄存器: 0x0004, 功能码: 0x06
|
||||||
|
"""
|
||||||
|
if not (1 <= new_id <= 254):
|
||||||
|
self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.info(f"Changing device ID from {self.device_id} to {new_id}")
|
||||||
|
success = self._write_single_register(0x0004, new_id)
|
||||||
|
if success:
|
||||||
|
self.device_id = new_id # 更新内存中的地址
|
||||||
|
self.logger.info(f"Device ID update command sent successfully (target {new_id}).")
|
||||||
|
return success
|
||||||
|
|
||||||
|
def change_baudrate(self, baud_code: int) -> bool:
|
||||||
|
"""
|
||||||
|
更改通讯波特率 (寄存器: 0x0005)。
|
||||||
|
设置成功后传感器 LED 会闪烁,通常无数据返回。
|
||||||
|
|
||||||
|
波特率代码对照表 (16进制):
|
||||||
|
05: 2400
|
||||||
|
06: 4800
|
||||||
|
07: 9600 (默认)
|
||||||
|
08: 14400
|
||||||
|
09: 19200
|
||||||
|
0A: 28800
|
||||||
|
0C: 57600
|
||||||
|
0D: 115200
|
||||||
|
0E: 128000
|
||||||
|
0F: 256000
|
||||||
|
"""
|
||||||
|
self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})")
|
||||||
|
# 写入寄存器 0x0005
|
||||||
|
self._write_single_register(0x0005, baud_code)
|
||||||
|
self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def factory_reset(self) -> bool:
|
||||||
|
"""
|
||||||
|
恢复出厂设置 (通过广播地址 FF)。
|
||||||
|
设置地址为 01,逻辑为向 0x0004 写入 0x0002
|
||||||
|
"""
|
||||||
|
self.logger.info("Sending factory reset command via broadcast address FF...")
|
||||||
|
# 广播指令通常无回显
|
||||||
|
self._write_single_register(0x0004, 0x0002, slave_id=0xFF)
|
||||||
|
self.logger.info("Factory reset command sent. Device address should be 01 now.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool:
|
||||||
|
"""内部辅助函数: Modbus 功能码 06 写单个寄存器"""
|
||||||
|
if not self.transport: return False
|
||||||
|
|
||||||
|
target_id = slave_id if slave_id is not None else self.device_id
|
||||||
|
msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value)
|
||||||
|
msg += self._crc(msg)
|
||||||
|
|
||||||
|
with self.transport.lock:
|
||||||
|
self.transport.clear_buffer()
|
||||||
|
if self.debug:
|
||||||
|
self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}")
|
||||||
|
|
||||||
|
self.transport.write(msg)
|
||||||
|
|
||||||
|
# 广播地址、波特率修改或厂家特定指令可能无回显
|
||||||
|
if target_id == 0xFF or reg_addr == 0x0005:
|
||||||
|
time.sleep(0.5)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 等待返回 (正常应返回相同报文)
|
||||||
|
resp = self.transport.read(len(msg))
|
||||||
|
if self.debug:
|
||||||
|
self.logger.info(f"RX Write Response: {resp.hex().upper()}")
|
||||||
|
|
||||||
|
return resp == msg
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.transport:
|
||||||
|
self.transport.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 快速实例化测试
|
||||||
|
import logging
|
||||||
|
# 减少冗余日志,仅显示重要信息
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||||
|
|
||||||
|
# 硬件配置 (根据实际情况修改)
|
||||||
|
TEST_PORT = "/dev/tty.usbserial-3110"
|
||||||
|
SLAVE_ID = 1
|
||||||
|
THRESHOLD = 300
|
||||||
|
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print(f" XKC RS485 传感器独立测试程序")
|
||||||
|
print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if sensor.transport:
|
||||||
|
print(f"\n开始实时连续采样测试 (持续 15 秒)...")
|
||||||
|
print(f"按 Ctrl+C 可提前停止\n")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
duration = 15
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
while time.time() - start_time < duration:
|
||||||
|
count += 1
|
||||||
|
res = sensor.read_level()
|
||||||
|
if res:
|
||||||
|
rssi = res['rssi']
|
||||||
|
level = res['level']
|
||||||
|
status_str = "【有液】" if level else "【无液】"
|
||||||
|
# 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史)
|
||||||
|
# 为了方便查看变化,我们直接打印
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}")
|
||||||
|
else:
|
||||||
|
print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)")
|
||||||
|
|
||||||
|
time.sleep(0.5) # 每秒采样 2 次
|
||||||
|
|
||||||
|
print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---")
|
||||||
|
|
||||||
|
# [3] 测试动态修改阈值
|
||||||
|
print(f"\n[3] 动态修改阈值演示...")
|
||||||
|
new_threshold = 400
|
||||||
|
sensor.set_threshold(new_threshold)
|
||||||
|
res = sensor.read_level()
|
||||||
|
if res:
|
||||||
|
print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}")
|
||||||
|
sensor.set_threshold(THRESHOLD) # 还原
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[!] 用户中断测试")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[!] 测试运行出错: {e}")
|
||||||
|
finally:
|
||||||
|
sensor.close()
|
||||||
|
print("\n--- 测试程序已退出 ---\n")
|
||||||
@@ -2,6 +2,8 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
from typing import Union, Dict, Optional
|
from typing import Union, Dict, Optional
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
|
|
||||||
|
|
||||||
class VirtualMultiwayValve:
|
class VirtualMultiwayValve:
|
||||||
"""
|
"""
|
||||||
@@ -41,13 +43,11 @@ class VirtualMultiwayValve:
|
|||||||
def target_position(self) -> int:
|
def target_position(self) -> int:
|
||||||
return self._target_position
|
return self._target_position
|
||||||
|
|
||||||
def get_current_position(self) -> int:
|
@property
|
||||||
"""获取当前阀门位置 📍"""
|
@topic_config()
|
||||||
return self._current_position
|
def current_port(self) -> str:
|
||||||
|
"""当前连接的端口名称 🔌"""
|
||||||
def get_current_port(self) -> str:
|
return self.port
|
||||||
"""获取当前连接的端口名称 🔌"""
|
|
||||||
return self._current_position
|
|
||||||
|
|
||||||
def set_position(self, command: Union[int, str]):
|
def set_position(self, command: Union[int, str]):
|
||||||
"""
|
"""
|
||||||
@@ -169,12 +169,14 @@ class VirtualMultiwayValve:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self._valve_state = "Closed"
|
self._valve_state = "Closed"
|
||||||
|
|
||||||
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})"
|
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
|
||||||
self.logger.info(close_msg)
|
self.logger.info(close_msg)
|
||||||
return close_msg
|
return close_msg
|
||||||
|
|
||||||
def get_valve_position(self) -> int:
|
@property
|
||||||
"""获取阀门位置 - 兼容性方法 📍"""
|
@topic_config()
|
||||||
|
def valve_position(self) -> int:
|
||||||
|
"""阀门位置 📍"""
|
||||||
return self._current_position
|
return self._current_position
|
||||||
|
|
||||||
def set_valve_position(self, command: Union[int, str]):
|
def set_valve_position(self, command: Union[int, str]):
|
||||||
@@ -229,19 +231,16 @@ class VirtualMultiwayValve:
|
|||||||
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
||||||
return self.set_to_pump_position()
|
return self.set_to_pump_position()
|
||||||
|
|
||||||
def get_flow_path(self) -> str:
|
@property
|
||||||
"""获取当前流路路径描述 🌊"""
|
@topic_config()
|
||||||
current_port = self.get_current_port()
|
def flow_path(self) -> str:
|
||||||
|
"""当前流路路径描述 🌊"""
|
||||||
if self._current_position == 0:
|
if self._current_position == 0:
|
||||||
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})"
|
return f"🚰 转移泵已连接 (位置 {self._current_position})"
|
||||||
else:
|
return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
|
||||||
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
|
|
||||||
|
|
||||||
# 删除debug日志:self.logger.debug(f"🌊 当前流路: {flow_path}")
|
|
||||||
return flow_path
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
current_port = self.get_current_port()
|
current_port = self.current_port
|
||||||
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
||||||
|
|
||||||
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
||||||
@@ -253,7 +252,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
print("🔄 === 虚拟九通阀门测试 === ✨")
|
print("🔄 === 虚拟九通阀门测试 === ✨")
|
||||||
print(f"🏠 初始状态: {valve}")
|
print(f"🏠 初始状态: {valve}")
|
||||||
print(f"🌊 当前流路: {valve.get_flow_path()}")
|
print(f"🌊 当前流路: {valve.flow_path}")
|
||||||
|
|
||||||
# 切换到试剂瓶1(1号位)
|
# 切换到试剂瓶1(1号位)
|
||||||
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import time as time_module
|
import time as time_module
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
class VirtualStirrer:
|
class VirtualStirrer:
|
||||||
@@ -314,9 +315,11 @@ class VirtualStirrer:
|
|||||||
def min_speed(self) -> float:
|
def min_speed(self) -> float:
|
||||||
return self._min_speed
|
return self._min_speed
|
||||||
|
|
||||||
def get_device_info(self) -> Dict[str, Any]:
|
@property
|
||||||
"""获取设备状态信息 📊"""
|
@topic_config()
|
||||||
info = {
|
def device_info(self) -> Dict[str, Any]:
|
||||||
|
"""设备状态快照信息 📊"""
|
||||||
|
return {
|
||||||
"device_id": self.device_id,
|
"device_id": self.device_id,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"operation_mode": self.operation_mode,
|
"operation_mode": self.operation_mode,
|
||||||
@@ -325,12 +328,9 @@ class VirtualStirrer:
|
|||||||
"is_stirring": self.is_stirring,
|
"is_stirring": self.is_stirring,
|
||||||
"remaining_time": self.remaining_time,
|
"remaining_time": self.remaining_time,
|
||||||
"max_speed": self._max_speed,
|
"max_speed": self._max_speed,
|
||||||
"min_speed": self._min_speed
|
"min_speed": self._min_speed,
|
||||||
}
|
}
|
||||||
|
|
||||||
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
|
|
||||||
return info
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
||||||
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
||||||
@@ -4,6 +4,7 @@ from enum import Enum
|
|||||||
from typing import Union, Optional
|
from typing import Union, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
|
|
||||||
@@ -385,8 +386,10 @@ class VirtualTransferPump:
|
|||||||
"""获取当前体积"""
|
"""获取当前体积"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
def get_remaining_capacity(self) -> float:
|
@property
|
||||||
"""获取剩余容量"""
|
@topic_config()
|
||||||
|
def remaining_capacity(self) -> float:
|
||||||
|
"""剩余容量 (ml)"""
|
||||||
return self.max_volume - self._current_volume
|
return self.max_volume - self._current_volume
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ from threading import Lock, RLock
|
|||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.registry.decorators import (
|
from unilabos.registry.decorators import (
|
||||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
|
||||||
)
|
)
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||||
|
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
|
||||||
|
|
||||||
|
|
||||||
# ============ TypedDict 返回类型定义 ============
|
# ============ TypedDict 返回类型定义 ============
|
||||||
@@ -290,6 +291,126 @@ class VirtualWorkbench:
|
|||||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||||
|
|
||||||
|
@action(
|
||||||
|
always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}, goal_default={
|
||||||
|
"timeout_seconds": 3600,
|
||||||
|
"assignee_user_ids": []
|
||||||
|
}, feedback_interval=300,
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(key="target_device", data_type="device_id",
|
||||||
|
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="resource", data_type="resource",
|
||||||
|
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||||
|
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
||||||
|
|
||||||
|
ActionInputHandle(key="collector_mass", data_type="collector_mass",
|
||||||
|
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="active_material", data_type="active_material",
|
||||||
|
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="capacity", data_type="capacity",
|
||||||
|
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="battery_system", data_type="battery_system",
|
||||||
|
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
|
||||||
|
# transfer使用
|
||||||
|
ActionOutputHandle(key="target_device", data_type="device_id",
|
||||||
|
label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR),
|
||||||
|
ActionOutputHandle(key="resource", data_type="resource",
|
||||||
|
label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR),
|
||||||
|
ActionOutputHandle(key="mount_resource", data_type="resource",
|
||||||
|
label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR),
|
||||||
|
# test使用
|
||||||
|
ActionOutputHandle(key="collector_mass", data_type="collector_mass",
|
||||||
|
label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR),
|
||||||
|
ActionOutputHandle(key="active_material", data_type="active_material",
|
||||||
|
label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR),
|
||||||
|
ActionOutputHandle(key="capacity", data_type="capacity",
|
||||||
|
label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR),
|
||||||
|
ActionOutputHandle(key="battery_system", data_type="battery_system",
|
||||||
|
label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def manual_confirm(
|
||||||
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
target_device: DeviceSlot,
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
collector_mass: List[float],
|
||||||
|
active_material: List[float],
|
||||||
|
capacity: List[float],
|
||||||
|
battery_system: List[str],
|
||||||
|
timeout_seconds: int,
|
||||||
|
assignee_user_ids: list[str],
|
||||||
|
**kwargs
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
timeout_seconds: 超时时间(秒),默认3600秒
|
||||||
|
collector_mass: 极流体质量
|
||||||
|
active_material: 活性物质含量
|
||||||
|
capacity: 克容量(mAh/g)
|
||||||
|
battery_system: 电池体系
|
||||||
|
修改的结果无效,是只读的
|
||||||
|
"""
|
||||||
|
resource = ResourceTreeSet.from_plr_resources(resource).dump()
|
||||||
|
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
|
||||||
|
kwargs.update(locals())
|
||||||
|
kwargs.pop("kwargs")
|
||||||
|
kwargs.pop("self")
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
@action(
|
||||||
|
description="转移物料",
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(key="target_device", data_type="device_id",
|
||||||
|
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="resource", data_type="resource",
|
||||||
|
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||||
|
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
|
||||||
|
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
|
||||||
|
**{
|
||||||
|
"plr_resources": resource,
|
||||||
|
"target_device_id": target_device,
|
||||||
|
"target_resources": mount_resource,
|
||||||
|
"sites": [None] * len(mount_resource),
|
||||||
|
})
|
||||||
|
result = await future
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@action(
|
||||||
|
description="扣电测试启动",
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(key="resource", data_type="resource",
|
||||||
|
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||||
|
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
||||||
|
|
||||||
|
ActionInputHandle(key="collector_mass", data_type="collector_mass",
|
||||||
|
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="active_material", data_type="active_material",
|
||||||
|
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="capacity", data_type="capacity",
|
||||||
|
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
|
||||||
|
ActionInputHandle(key="battery_system", data_type="battery_system",
|
||||||
|
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
async def test(
|
||||||
|
self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str]
|
||||||
|
):
|
||||||
|
print(resource)
|
||||||
|
print(mount_resource)
|
||||||
|
print(collector_mass)
|
||||||
|
print(active_material)
|
||||||
|
print(capacity)
|
||||||
|
print(battery_system)
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -760,9 +760,10 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 创建通信模块;同步器将在 post_init 中初始化并执行首次同步
|
# 创建通信模块
|
||||||
self._create_communication_module(bioyond_config)
|
self._create_communication_module(bioyond_config)
|
||||||
self.resource_synchronizer = None
|
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
|
||||||
# TODO: self._ros_node里面拿属性
|
# TODO: self._ros_node里面拿属性
|
||||||
|
|
||||||
@@ -801,15 +802,6 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||||
self._ros_node = ros_node
|
self._ros_node = ros_node
|
||||||
|
|
||||||
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化仓库
|
|
||||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
|
||||||
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
|
|
||||||
self.deck.setup()
|
|
||||||
|
|
||||||
# 初始化同步器并执行首次同步(需在仓库初始化之后)
|
|
||||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
|
||||||
self.resource_synchronizer.sync_from_external()
|
|
||||||
|
|
||||||
# 启动连接监控
|
# 启动连接监控
|
||||||
try:
|
try:
|
||||||
self.connection_monitor = ConnectionMonitor(self)
|
self.connection_monitor = ConnectionMonitor(self)
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
# 代码变更说明 — 2026-03-12
|
|
||||||
|
|
||||||
> 本次变更基于 `implementation_plan_v2.md` 执行,目标:**物理几何结构初始化与物料内容物填充彻底解耦**,消除 PLR 反序列化时的 `Resource already assigned to deck` 错误,并修复若干运行时新增问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、物料系统标准化重构(主线任务)
|
|
||||||
|
|
||||||
### 1. `unilabos/resources/battery/magazine.py`
|
|
||||||
|
|
||||||
**改动**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 三个工厂函数的 `klasses` 参数改为 `None`。
|
|
||||||
|
|
||||||
**原因**:原来三个工厂函数在初始化时就向洞位填满极片对象(`ElectrodeSheet`),导致 PLR 反序列化时"几何结构已创建子节点 + DB 再次 assign"双重冲突。
|
|
||||||
|
|
||||||
**原则**:物料余量改由寄存器直读(阶段 F),资源树不再追踪每个极片实体。`MagazineHolder_6_Battery` 原本就是 `klasses=None`,三者现在保持一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `unilabos/resources/battery/magazine.py`(追加,响应重复 UUID 问题)
|
|
||||||
|
|
||||||
**改动**:为 `Magazine`(洞位类)新增 `serialize` 和 `deserialize` 重写:
|
|
||||||
- `serialize`:序列化时强制将 `children` 置空,不再把极片写回数据库。
|
|
||||||
- `deserialize`:反序列化时强制忽略 `children` 字段,阻止数据库中旧极片记录被恢复。
|
|
||||||
|
|
||||||
**原因**:数据库中遗留有旧的 `ElectrodeSheet` 记录(`A1_sheet100` 等),启动时被 PLR 反序列化进来,导致同一 UUID 出现在多个 Magazine 洞位中,触发 `发现重复的uuid` 错误。此修复从源头截断旧数据,经过一次完整的"启动 → 资源树写回"后,数据库旧极片记录也会被干净覆盖。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. `unilabos/resources/battery/bottle_carriers.py`
|
|
||||||
|
|
||||||
**改动**:删除 `YIHUA_Electrolyte_12VialCarrier` 末尾的 12 瓶填充循环及对应 `import`。
|
|
||||||
|
|
||||||
**原因**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 应初始化为空载架,瓶子由 Bioyond 侧实际转运后再填入。原来初始化时直接塞满 `YB_pei_ye_xiao_Bottle`,反序列化时产生重复 assign。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `unilabos/resources/bioyond/decks.py`
|
|
||||||
|
|
||||||
**改动**:
|
|
||||||
- 将 `BIOYOND_YB_Deck` 重命名为 `BioyondElectrolyteDeck`,保留 `BIOYOND_YB_Deck` 作为向后兼容别名。
|
|
||||||
- 工厂函数 `YB_Deck()` 重命名为 `bioyond_electrolyte_deck()`,保留 `YB_Deck` 作为别名。
|
|
||||||
- `BIOYOND_PolymerReactionStation_Deck`、`BIOYOND_PolymerPreparationStation_Deck`、`BioyondElectrolyteDeck` 三个 Deck 类:
|
|
||||||
- 移除 `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用。
|
|
||||||
- 删除临时 `deserialize` 补丁(该补丁是为了强制 `setup=False`,根本原因消除后不再需要)。
|
|
||||||
|
|
||||||
**原因**:`setup` 参数导致 PLR 反序列化时先通过 `__init__` 创建所有子资源,再从 JSON `children` 字段再次 assign,产生 `already assigned to deck` 错误。正确模式:`__init__` 只初始化自身几何,`setup()` 由工厂函数调用,反序列化由 PLR 从 DB 数据重建子资源。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
|
|
||||||
|
|
||||||
**改动**:
|
|
||||||
- `CoincellDeck` 重命名为 `YihuaCoinCellDeck`,保留 `CoincellDeck` 作为向后兼容别名。
|
|
||||||
- 工厂函数 `YH_Deck()` 重命名为 `yihua_coin_cell_deck()`,保留 `YH_Deck` 作为别名。
|
|
||||||
- 移除 `YihuaCoinCellDeck.__init__` 中的 `setup: bool = False` 参数及调用,删除 `deserialize` 补丁(原因同 decks.py)。
|
|
||||||
- `MaterialPlate.__init__` 移除 `fill` 参数和 `fill=True` 分支,新增类方法 `MaterialPlate.create_with_holes()` 作为"带洞位"的工厂方法,`setup()` 改为调用该工厂方法。
|
|
||||||
- `YihuaCoinCellDeck.setup()` 末尾新增 `electrolyte_buffer`(`ResourceStack`)接驳槽,用于接收来自 Bioyond 侧的分液瓶板,命名与 `bioyond_cell_workstation.py` 中 `sites=["electrolyte_buffer"]` 一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. `unilabos/resources/resource_tracker.py`
|
|
||||||
|
|
||||||
**改动 1**:`to_plr_resources` 中,`load_all_state` 调用前预填 `Container` 类资源缺失的键:
|
|
||||||
|
|
||||||
```python
|
|
||||||
state.setdefault("liquid_history", [])
|
|
||||||
state.setdefault("pending_liquids", {})
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:新版 PLR 要求 `Container` 状态中必须包含这两个键,旧数据库记录缺失时 `load_all_state` 会抛出 `KeyError`。
|
|
||||||
|
|
||||||
**改动 2**:`_validate_tree` 中,遇到重复 UUID 时改为自动重新分配新 UUID 并打 `WARNING`,不再直接抛异常崩溃。
|
|
||||||
|
|
||||||
**原因**:旧数据库中存在多个同名同 UUID 的极片对象(历史脏数据),严格校验会导致节点无法启动。改为 WARNING + 自动修复,确保启动成功,下次资源树写回后脏数据自然清除。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. `unilabos/resources/itemized_carrier.py`
|
|
||||||
|
|
||||||
**改动**:将原来的 `idx is None` 兜底补丁(静默调用 `super().assign_child_resource`,不更新槽位追踪)替换为两段式逻辑:
|
|
||||||
|
|
||||||
1. **XY 近似匹配**(容差 2mm):精确三维坐标匹配失败时,仅对比 XY 二维坐标,找到最近槽位后用槽位的正确坐标(含 Z)完成 assign,并打 `WARNING`。
|
|
||||||
2. **XY 也失败才抛异常**:给出详细的槽位列表和传入坐标,便于问题排查。
|
|
||||||
|
|
||||||
**原因**:数据库中存储的资源坐标 Z=0,而 `warehouse_factory` 定义的槽位 Z=dz(如 10mm)。精确匹配永远失败,原补丁静默兜底掩盖了这一问题。近似匹配修复了 Z 偏移,同时保留了真正异常时的报错能力。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
|
||||||
|
|
||||||
**改动 1**:更新导入:`BIOYOND_YB_Deck` → `BioyondElectrolyteDeck, bioyond_electrolyte_deck`。
|
|
||||||
|
|
||||||
**改动 2**:`__main__` 入口处改为调用 `bioyond_electrolyte_deck(name="YB_Deck")`。
|
|
||||||
|
|
||||||
**改动 3**:新增 `_get_resource_from_device(device_id, resource_name)` 方法,用于从目标设备的资源树中动态查找 PLR 资源对象(带降级回退逻辑)。
|
|
||||||
|
|
||||||
**改动 4**:跨站转运逻辑中,将原来"创建 `size=1,1,1` 的虚拟 `ResourcePLR` + 硬编码 UUID"的方式,改为通过 `_get_resource_from_device` 从目标设备获取真实的 `electrolyte_buffer` 资源对象。
|
|
||||||
|
|
||||||
**原因**:原代码使用硬编码 UUID 的虚拟资源作为转运目标,该对象在 YihuaCoinCellDeck 的资源树中不存在,转移后资源树状态混乱。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
|
||||||
|
|
||||||
**改动 1**:更新导入:`CoincellDeck` → `YihuaCoinCellDeck, yihua_coin_cell_deck`,`__main__` 入口改为调用 `yihua_coin_cell_deck()`。
|
|
||||||
|
|
||||||
**改动 2**:新增 10 个 `@property`,实现对依华扣电工站 Modbus 寄存器的直读:
|
|
||||||
|
|
||||||
| 属性名 | 寄存器地址 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `data_10mm_positive_plate_remaining` | 520 | 10mm正极片余量 |
|
|
||||||
| `data_12mm_positive_plate_remaining` | 522 | 12mm正极片余量 |
|
|
||||||
| `data_16mm_positive_plate_remaining` | 524 | 16mm正极片余量 |
|
|
||||||
| `data_aluminum_foil_remaining` | 526 | 铝箔余量 |
|
|
||||||
| `data_positive_shell_remaining` | 528 | 正极壳余量 |
|
|
||||||
| `data_flat_washer_remaining` | 530 | 平垫余量 |
|
|
||||||
| `data_negative_shell_remaining` | 532 | 负极壳余量 |
|
|
||||||
| `data_spring_washer_remaining` | 534 | 弹垫余量 |
|
|
||||||
| `data_finished_battery_remaining_capacity` | 536 | 成品电池余量 |
|
|
||||||
| `data_finished_battery_ng_remaining_capacity` | 538 | 成品电池NG槽余量 |
|
|
||||||
|
|
||||||
**原因**:`coin_cell_workstation.yaml` 的 `status_types` 中定义了这 10 个属性,但代码中从未实现,导致每次前端轮询时均报 `AttributeError`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、配置与注册表更新
|
|
||||||
|
|
||||||
### 10. `yibin_electrolyte_config.json`
|
|
||||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`(class、type、_resource_type 三处)
|
|
||||||
- `CoincellDeck` → `YihuaCoinCellDeck`(class、type、_resource_type 三处)
|
|
||||||
- 移除 `"setup": true` 字段
|
|
||||||
|
|
||||||
### 11. `yibin_coin_cell_only_config.json`
|
|
||||||
- `CoincellDeck` → `YihuaCoinCellDeck`
|
|
||||||
- 移除 `"setup": true`
|
|
||||||
|
|
||||||
### 12. `yibin_electrolyte_only_config.json`
|
|
||||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`
|
|
||||||
- 移除 `"setup": true`
|
|
||||||
|
|
||||||
### 13. `unilabos/registry/resources/bioyond/deck.yaml`
|
|
||||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`,工厂函数路径更新为 `bioyond_electrolyte_deck`
|
|
||||||
- `CoincellDeck` → `YihuaCoinCellDeck`,工厂函数路径更新为 `yihua_coin_cell_deck`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、独立 Bug 修复
|
|
||||||
|
|
||||||
### 14. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv`
|
|
||||||
|
|
||||||
**改动**:10 条余量寄存器记录的 `DataType` 列从 `REAL` 改为 `FLOAT32`。
|
|
||||||
|
|
||||||
**原因**:`REAL` 是 IEC 61131-3 PLC 工程师惯用名称,但 pymodbus 的 `DATATYPE` 枚举只有 `FLOAT32`,`DataType['REAL']` 查表时抛 `KeyError: 'REAL'`,导致 `CoinCellAssemblyWorkstation` 节点启动失败。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、运行期新增 Bug 修复(第二轮,2026-03-12 18:12 日志)
|
|
||||||
|
|
||||||
### 15. `unilabos/devices/workstation/bioyond_studio/station.py`
|
|
||||||
|
|
||||||
**改动**:第 261 行 `self.bioyond_config` → `self.workstation.bioyond_config`。
|
|
||||||
|
|
||||||
**原因**:`BioyondResourceSynchronizer.sync_to_external` 内部误用了 `self.bioyond_config`,而该类从未设置此属性(应通过 `self.workstation.bioyond_config` 访问)。触发场景:用户在前端将任意物料拖入仓库时,同步到 Bioyond 必定抛出 `AttributeError: 'BioyondResourceSynchronizer' object has no attribute 'bioyond_config'`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 16. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
|
||||||
|
|
||||||
**改动**:`_get_type_id_by_name` 方法新增"直接英文 key 命中"分支:
|
|
||||||
|
|
||||||
- **原逻辑**:仅按 `value[0]`(中文名,如 `"5ml分液瓶板"`)遍历比较。
|
|
||||||
- **新逻辑**:先以 `type_name` 直接查找 `material_type_mappings` 字典 key(英文 model 名,如 `"YB_Vial_5mL_Carrier"`),命中则立即返回 UUID;否则再按中文名兜底遍历。
|
|
||||||
|
|
||||||
**原因**:`resource_tree_transfer` 将 `plr_resource.model`(英文 key)作为 `board_type` / `bottle_type` 传给 `create_sample`,后者再调用 `_get_type_id_by_name`。旧版函数只按中文名查,导致英文 key 永远匹配不到 → `ValueError: 未找到板类型 'YB_Vial_5mL_Carrier' 的配置`。新函数兼容两种查找方式,同时保持向后兼容。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、运行期新增 Bug 修复(第三轮,2026-03-12 20:30 日志)
|
|
||||||
|
|
||||||
### 17. `unilabos/resources/resource_tracker.py`(追加)
|
|
||||||
|
|
||||||
**改动**:在 `to_plr_resources` 中,`sub_cls.deserialize` 调用前新增 `_deduplicate_plr_dict(plr_dict)` 预处理函数。
|
|
||||||
|
|
||||||
**函数逻辑**:递归遍历整个 `plr_dict` 树,在**全树范围**对 `children` 列表按 `name` 去重——保留首次出现的同名节点,跳过重复项并打 `WARNING`。
|
|
||||||
|
|
||||||
**根本原因**:
|
|
||||||
1. 用户通过前端将 `YB_Vial_5mL_Carrier` 拖入仓库 E01,carrier 及其子 vial(`YB_Vial_5mL_Carrier_vial_A1` 等)被写入数据库。
|
|
||||||
2. 随后 `sync_from_external`(Bioyond 定期同步)以**新 UUID** 重新创建同名 carrier 并赋给同一槽位,PLR 内存树中的旧 carrier 被替换,但**数据库旧记录未被清除**。
|
|
||||||
3. 下次重启时,数据库同一 `WareHouse` 下存在两条同名 `BottleCarrier`(不同 UUID),`node_to_plr_dict` 将二者都放入 `children` 列表,PLR 反序列化第二个 carrier 时子 vial 命名冲突,抛出 `ValueError: Resource with name 'YB_Vial_5mL_Carrier_vial_A1' already exists in the tree.`,整个 deck 无法加载,系统启动失败。
|
|
||||||
|
|
||||||
**连锁错误(随根因修复自动消除)**:
|
|
||||||
- `TypeError: Deck.__init__() got an unexpected keyword argument 'data'` — deck 加载失败后 `driver_creator.py` 触发降级路径,参数类型错误
|
|
||||||
- `AttributeError: 'ResourceDictInstance' object has no attribute 'copy'` — 另一条降级路径失败
|
|
||||||
- `ValueError: Deck 配置不能为空` — 所有 deck 创建路径失败,`deck=None` 传入工作站
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **验证状态**:2026-03-12 20:56 日志确认系统正常运行,无新增 ERROR 级错误。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、变更文件汇总(最终)
|
|
||||||
|
|
||||||
| 文件 | 变更类型 | 轮次 |
|
|
||||||
|---|---|---|
|
|
||||||
| `resources/battery/magazine.py` | 重构 + Bug 修复(极片子节点解耦 + 旧数据清理) | 第一轮 |
|
|
||||||
| `resources/battery/bottle_carriers.py` | 重构(移除初始化时自动填瓶) | 第一轮 |
|
|
||||||
| `resources/bioyond/decks.py` | 重构 + 重命名(BioyondElectrolyteDeck) | 第一轮 |
|
|
||||||
| `devices/workstation/coin_cell_assembly/YB_YH_materials.py` | 重构 + 重命名(YihuaCoinCellDeck)+ 新增 electrolyte_buffer 槽位 | 第一轮 |
|
|
||||||
| `resources/resource_tracker.py` | Bug 修复 × 3(Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重) | 第一/三轮 |
|
|
||||||
| `resources/itemized_carrier.py` | Bug 修复(XY 近似坐标匹配,修复 Z 偏移) | 第一轮 |
|
|
||||||
| `devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | 重构 + Bug 修复(跨站转运 + 类型映射双模式查找) | 第一/二轮 |
|
|
||||||
| `devices/workstation/bioyond_studio/station.py` | Bug 修复(sync_to_external 属性访问路径) | 第二轮 |
|
|
||||||
| `devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | 新增 10 个 Modbus 余量属性 + 更新导入 | 第一轮 |
|
|
||||||
| `yibin_electrolyte_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
|
||||||
| `yibin_coin_cell_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
|
||||||
| `yibin_electrolyte_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
|
||||||
| `registry/resources/bioyond/deck.yaml` | 注册表更新(类名 + 工厂函数路径) | 第一轮 |
|
|
||||||
| `devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` | Bug 修复(REAL → FLOAT32) | 第一轮 |
|
|
||||||
@@ -130,14 +130,20 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
|
|||||||
ordering: Optional[OrderedDict[str, str]] = None,
|
ordering: Optional[OrderedDict[str, str]] = None,
|
||||||
category: str = "material_plate",
|
category: str = "material_plate",
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
|
fill: bool = False
|
||||||
):
|
):
|
||||||
"""初始化料板(不主动填充洞位,由工厂方法或反序列化恢复)
|
"""初始化料板
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: 料板名称
|
name: 料板名称
|
||||||
size_x: 长度 (mm)
|
size_x: 长度 (mm)
|
||||||
size_y: 宽度 (mm)
|
size_y: 宽度 (mm)
|
||||||
size_z: 高度 (mm)
|
size_z: 高度 (mm)
|
||||||
|
hole_diameter: 洞直径 (mm)
|
||||||
|
hole_depth: 洞深度 (mm)
|
||||||
|
hole_spacing_x: X方向洞位间距 (mm)
|
||||||
|
hole_spacing_y: Y方向洞位间距 (mm)
|
||||||
|
number: 编号
|
||||||
category: 类别
|
category: 类别
|
||||||
model: 型号
|
model: 型号
|
||||||
"""
|
"""
|
||||||
@@ -147,50 +153,42 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
|
|||||||
hole_diameter=20.0,
|
hole_diameter=20.0,
|
||||||
info="",
|
info="",
|
||||||
)
|
)
|
||||||
super().__init__(
|
# 创建4x4的洞位
|
||||||
name=name,
|
# TODO: 这里要改,对应不同形状
|
||||||
size_x=size_x,
|
|
||||||
size_y=size_y,
|
|
||||||
size_z=size_z,
|
|
||||||
ordered_items=ordered_items,
|
|
||||||
ordering=ordering,
|
|
||||||
category=category,
|
|
||||||
model=model,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_with_holes(
|
|
||||||
cls,
|
|
||||||
name: str,
|
|
||||||
size_x: float,
|
|
||||||
size_y: float,
|
|
||||||
size_z: float,
|
|
||||||
category: str = "material_plate",
|
|
||||||
model: Optional[str] = None,
|
|
||||||
) -> "MaterialPlate":
|
|
||||||
"""工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup,不在反序列化路径调用)"""
|
|
||||||
# 默认洞位间距(与 _unilabos_state 默认值保持一致)
|
|
||||||
hole_spacing_x = 24.0
|
|
||||||
hole_spacing_y = 24.0
|
|
||||||
# 先建洞位,再作为 ordered_items 传入构造函数
|
|
||||||
# (ItemizedResource.__init__ 要求 ordered_items 或 ordering 二选一必须有值)
|
|
||||||
holes = create_ordered_items_2d(
|
holes = create_ordered_items_2d(
|
||||||
klass=MaterialHole,
|
klass=MaterialHole,
|
||||||
num_items_x=4,
|
num_items_x=4,
|
||||||
num_items_y=4,
|
num_items_y=4,
|
||||||
dx=(size_x - 4 * hole_spacing_x) / 2,
|
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||||
dy=(size_y - 4 * hole_spacing_y) / 2,
|
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||||
dz=size_z,
|
dz=size_z,
|
||||||
item_dx=hole_spacing_x,
|
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||||
item_dy=hole_spacing_y,
|
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||||
size_x=16,
|
size_x = 16,
|
||||||
size_y=16,
|
size_y = 16,
|
||||||
size_z=16,
|
size_z = 16,
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
name=name, size_x=size_x, size_y=size_y, size_z=size_z,
|
|
||||||
ordered_items=holes, category=category, model=model,
|
|
||||||
)
|
)
|
||||||
|
if fill:
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=holes,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=ordered_items,
|
||||||
|
ordering=ordering,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
def update_locations(self):
|
def update_locations(self):
|
||||||
# TODO:调多次相加
|
# TODO:调多次相加
|
||||||
@@ -536,19 +534,30 @@ class WasteTipBox(Trash):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class YihuaCoinCellDeck(Deck):
|
class CoincellDeck(Deck):
|
||||||
"""依华纽扣电池组装工作站台面类"""
|
"""纽扣电池组装工作站台面类"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str = "coin_cell_deck",
|
name: str = "coin_cell_deck",
|
||||||
size_x: float = 1450.0,
|
size_x: float = 1450.0, # 1m
|
||||||
size_y: float = 1450.0,
|
size_y: float = 1450.0, # 1m
|
||||||
size_z: float = 100.0,
|
size_z: float = 100.0, # 0.9m
|
||||||
origin: Coordinate = Coordinate(-2200, 0, 0),
|
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||||
category: str = "coin_cell_deck",
|
category: str = "coin_cell_deck",
|
||||||
setup: bool = False,
|
setup: bool = False, # 是否自动执行 setup
|
||||||
):
|
):
|
||||||
|
"""初始化纽扣电池组装工作站台面
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 台面名称
|
||||||
|
size_x: 长度 (mm) - 1m
|
||||||
|
size_y: 宽度 (mm) - 1m
|
||||||
|
size_z: 高度 (mm) - 0.9m
|
||||||
|
origin: 原点坐标
|
||||||
|
category: 类别
|
||||||
|
setup: 是否自动执行 setup 配置标准布局
|
||||||
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name=name,
|
name=name,
|
||||||
size_x=1450.0,
|
size_x=1450.0,
|
||||||
@@ -582,11 +591,14 @@ class YihuaCoinCellDeck(Deck):
|
|||||||
# ====================================== 物料板 ============================================
|
# ====================================== 物料板 ============================================
|
||||||
# 创建物料板(料盘carrier)- 4x4布局
|
# 创建物料板(料盘carrier)- 4x4布局
|
||||||
# 负极料盘
|
# 负极料盘
|
||||||
fujiliaopan = MaterialPlate.create_with_holes(name="负极料盘", size_x=120, size_y=100, size_z=10.0)
|
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||||
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||||
|
# for i in range(16):
|
||||||
|
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||||
|
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||||
|
|
||||||
# 隔膜料盘
|
# 隔膜料盘
|
||||||
gemoliaopan = MaterialPlate.create_with_holes(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0)
|
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||||
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||||
# for i in range(16):
|
# for i in range(16):
|
||||||
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||||
@@ -621,27 +633,11 @@ class YihuaCoinCellDeck(Deck):
|
|||||||
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||||
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||||
|
|
||||||
# 分液瓶板接驳区 - 接收来自 BioyondElectrolyte 侧的完整 Vial Carrier 板
|
|
||||||
# 命名 electrolyte_buffer 与 bioyond_cell_workstation.py 中 sites=["electrolyte_buffer"] 对应
|
|
||||||
electrolyte_buffer = ResourceStack(
|
|
||||||
name="electrolyte_buffer",
|
|
||||||
direction="z",
|
|
||||||
resources=[],
|
|
||||||
)
|
|
||||||
self.assign_child_resource(electrolyte_buffer, Coordinate(x=1050.0, y=700.0, z=0))
|
|
||||||
|
|
||||||
|
def YH_Deck(name=""):
|
||||||
def yihua_coin_cell_deck(name: str = "coin_cell_deck") -> YihuaCoinCellDeck:
|
cd = CoincellDeck(name=name)
|
||||||
deck = YihuaCoinCellDeck(name=name)
|
cd.setup()
|
||||||
deck.setup()
|
return cd
|
||||||
return deck
|
|
||||||
|
|
||||||
|
|
||||||
# 向后兼容别名,日后废弃
|
|
||||||
CoincellDeck = YihuaCoinCellDeck
|
|
||||||
|
|
||||||
def YH_Deck(name: str = "") -> YihuaCoinCellDeck:
|
|
||||||
return yihua_coin_cell_deck(name=name or "coin_cell_deck")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from unilabos.device_comms.modbus_plc.modbus import DeviceType, Base as ModbusNo
|
|||||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import *
|
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import *
|
||||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
|
||||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck, yihua_coin_cell_deck
|
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
|
||||||
from unilabos.resources.graphio import convert_resources_to_type
|
from unilabos.resources.graphio import convert_resources_to_type
|
||||||
from unilabos.utils.log import logger
|
from unilabos.utils.log import logger
|
||||||
import struct
|
import struct
|
||||||
@@ -161,9 +161,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
logger.info("没有传入依华deck,检查启动json文件")
|
logger.info("没有传入依华deck,检查启动json文件")
|
||||||
super().__init__(deck=deck, *args, **kwargs,)
|
super().__init__(deck=deck, *args, **kwargs,)
|
||||||
self.debug_mode = debug_mode
|
self.debug_mode = debug_mode
|
||||||
self._modbus_address = address
|
|
||||||
self._modbus_port = port
|
|
||||||
|
|
||||||
""" 连接初始化 """
|
""" 连接初始化 """
|
||||||
modbus_client = TCPClient(addr=address, port=port)
|
modbus_client = TCPClient(addr=address, port=port)
|
||||||
logger.debug(f"创建 Modbus 客户端: {modbus_client}")
|
logger.debug(f"创建 Modbus 客户端: {modbus_client}")
|
||||||
@@ -180,11 +178,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
raise ValueError('modbus tcp connection failed')
|
raise ValueError('modbus tcp connection failed')
|
||||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_b.csv'))
|
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_b.csv'))
|
||||||
self.client = modbus_client.register_node_list(self.nodes)
|
self.client = modbus_client.register_node_list(self.nodes)
|
||||||
self._modbus_client_raw = modbus_client
|
|
||||||
else:
|
else:
|
||||||
print("测试模式,跳过连接")
|
print("测试模式,跳过连接")
|
||||||
self.nodes, self.client = None, None
|
self.nodes, self.client = None, None
|
||||||
self._modbus_client_raw = None
|
|
||||||
|
|
||||||
""" 工站的配置 """
|
""" 工站的配置 """
|
||||||
|
|
||||||
@@ -195,40 +191,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
self.csv_export_file = None
|
self.csv_export_file = None
|
||||||
self.coin_num_N = 0 #已组装电池数量
|
self.coin_num_N = 0 #已组装电池数量
|
||||||
|
|
||||||
def _ensure_modbus_connected(self) -> None:
|
|
||||||
"""检查 Modbus TCP 连接是否存活,若已断开则自动重连(防止长时间空闲后连接超时)"""
|
|
||||||
if self.debug_mode or self._modbus_client_raw is None:
|
|
||||||
return
|
|
||||||
raw_client = self._modbus_client_raw.client
|
|
||||||
if raw_client.is_socket_open():
|
|
||||||
return
|
|
||||||
logger.warning("[Modbus] 检测到连接已断开,尝试重连...")
|
|
||||||
try:
|
|
||||||
raw_client.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
count = 10
|
|
||||||
while count > 0:
|
|
||||||
count -= 1
|
|
||||||
try:
|
|
||||||
raw_client.connect()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if raw_client.is_socket_open():
|
|
||||||
break
|
|
||||||
time.sleep(2)
|
|
||||||
if not raw_client.is_socket_open():
|
|
||||||
raise RuntimeError(f"Modbus TCP 重连失败({self._modbus_address}:{self._modbus_port}),请检查设备连接")
|
|
||||||
logger.info("[Modbus] 重连成功")
|
|
||||||
|
|
||||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||||
self._ros_node = ros_node
|
self._ros_node = ros_node
|
||||||
|
#self.deck = create_a_coin_cell_deck()
|
||||||
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化子物料
|
|
||||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
|
||||||
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
|
|
||||||
self.deck.setup()
|
|
||||||
|
|
||||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||||
"resources": [self.deck]
|
"resources": [self.deck]
|
||||||
})
|
})
|
||||||
@@ -658,28 +623,12 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
return vol
|
return vol
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data_coin_type(self) -> int:
|
def data_coin_num(self) -> int:
|
||||||
"""电池类型 - 7种或8种组装物料 (INT16)"""
|
"""当前电池数量 (INT16)"""
|
||||||
if self.debug_mode:
|
|
||||||
return 7
|
|
||||||
coin_type, read_err = self.client.use_node('REG_DATA_COIN_TYPE').read(1)
|
|
||||||
return coin_type
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_current_assembling_count(self) -> int:
|
|
||||||
"""当前进行组装的电池数量 - Current assembling battery count (INT16)"""
|
|
||||||
if self.debug_mode:
|
if self.debug_mode:
|
||||||
return 0
|
return 0
|
||||||
count, read_err = self.client.use_node('REG_DATA_CURRENT_ASSEMBLING_COUNT').read(1)
|
num, read_err = self.client.use_node('REG_DATA_COIN_NUM').read(1)
|
||||||
return count
|
return num
|
||||||
|
|
||||||
@property
|
|
||||||
def data_current_completed_count(self) -> int:
|
|
||||||
"""当前完成组装的电池数量 - Current completed battery count (INT16)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0
|
|
||||||
count, read_err = self.client.use_node('REG_DATA_CURRENT_COMPLETED_COUNT').read(1)
|
|
||||||
return count
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data_coin_cell_code(self) -> str:
|
def data_coin_cell_code(self) -> str:
|
||||||
@@ -777,116 +726,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
return 0.0
|
return 0.0
|
||||||
return _decode_float32_correct(result.registers)
|
return _decode_float32_correct(result.registers)
|
||||||
|
|
||||||
@property
|
|
||||||
def data_10mm_positive_plate_remaining(self) -> float:
|
|
||||||
"""10mm正极片剩余物料数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取10mm正极片余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_12mm_positive_plate_remaining(self) -> float:
|
|
||||||
"""12mm正极片剩余物料数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取12mm正极片余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_16mm_positive_plate_remaining(self) -> float:
|
|
||||||
"""16mm正极片剩余物料数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取16mm正极片余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_aluminum_foil_remaining(self) -> float:
|
|
||||||
"""铝箔剩余物料数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取铝箔余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_positive_shell_remaining(self) -> float:
|
|
||||||
"""正极壳剩余物料数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_POSITIVE_SHELL_REMAINING_COUNT').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取正极壳余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_flat_washer_remaining(self) -> float:
|
|
||||||
"""平垫剩余物料数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FLAT_WASHER_REMAINING_COUNT').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取平垫余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_negative_shell_remaining(self) -> float:
|
|
||||||
"""负极壳剩余物料数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取负极壳余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_spring_washer_remaining(self) -> float:
|
|
||||||
"""弹垫剩余物料数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_SPRING_WASHER_REMAINING_COUNT').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取弹垫余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_finished_battery_remaining_capacity(self) -> float:
|
|
||||||
"""成品电池剩余可容纳数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取成品电池余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_finished_battery_ng_remaining_capacity(self) -> float:
|
|
||||||
"""成品电池NG槽剩余可容纳数量 (FLOAT32)"""
|
|
||||||
if self.debug_mode:
|
|
||||||
return 0.0
|
|
||||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY').address, count=2)
|
|
||||||
if result.isError():
|
|
||||||
logger.error("读取成品电池NG槽余量失败")
|
|
||||||
return 0.0
|
|
||||||
return _decode_float32_correct(result.registers)
|
|
||||||
|
|
||||||
# @property
|
# @property
|
||||||
# def data_stack_vision_code(self) -> int:
|
# def data_stack_vision_code(self) -> int:
|
||||||
# """物料堆叠复检图片编码 (INT16)"""
|
# """物料堆叠复检图片编码 (INT16)"""
|
||||||
@@ -1086,7 +925,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
# 步骤0: 前置条件检查
|
# 步骤0: 前置条件检查
|
||||||
logger.info("\n【步骤 0/4】前置条件检查...")
|
logger.info("\n【步骤 0/4】前置条件检查...")
|
||||||
self._ensure_modbus_connected()
|
|
||||||
try:
|
try:
|
||||||
# 检查 REG_UNILAB_INTERACT (应该为False,表示使用Unilab交互)
|
# 检查 REG_UNILAB_INTERACT (应该为False,表示使用Unilab交互)
|
||||||
unilab_interact_node = self.client.use_node('REG_UNILAB_INTERACT')
|
unilab_interact_node = self.client.use_node('REG_UNILAB_INTERACT')
|
||||||
@@ -1147,42 +985,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
raise RuntimeError(error_msg)
|
raise RuntimeError(error_msg)
|
||||||
|
|
||||||
logger.info(" ✓ COIL_GB_L_IGNORE_CMD 检查通过 (值为False,使用左手套箱)")
|
logger.info(" ✓ COIL_GB_L_IGNORE_CMD 检查通过 (值为False,使用左手套箱)")
|
||||||
|
|
||||||
# 检查握手寄存器残留(正常初始状态均应为False)
|
|
||||||
# 若上次运行意外断网,这些Unilab侧COIL可能被遗留为True,导致PLC逻辑卡死
|
|
||||||
handshake_checks = [
|
|
||||||
("COIL_UNILAB_SEND_MSG_SUCC_CMD", "Unilab→PLC 配方发送完毕", "上次配方握手未正常复位,PLC可能处于等待配方的卡死状态"),
|
|
||||||
("COIL_UNILAB_REC_MSG_SUCC_CMD", "Unilab→PLC 数据接收完毕", "上次数据接收握手未正常复位"),
|
|
||||||
("UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM", "Unilab→PLC 瓶数发送完毕", "上次瓶数握手未正常复位"),
|
|
||||||
("UNILAB_SEND_FINISHED_CMD", "Unilab→PLC 一组完成确认", "上次完成握手未正常复位"),
|
|
||||||
("COIL_REQUEST_REC_MSG_STATUS", "PLC→Unilab 请求接收配方", "PLC正处于等待配方状态,设备流程已卡死,需重启PLC或手动复位握手"),
|
|
||||||
("COIL_REQUEST_SEND_MSG_STATUS", "PLC→Unilab 请求发送测试数据", "PLC正处于等待发送数据状态,设备流程已卡死"),
|
|
||||||
]
|
|
||||||
for coil_name, coil_desc, stuck_reason in handshake_checks:
|
|
||||||
try:
|
|
||||||
hs_node = self.client.use_node(coil_name)
|
|
||||||
hs_value, hs_err = hs_node.read(1)
|
|
||||||
if hs_err:
|
|
||||||
logger.warning(f" ⚠ 无法读取 {coil_name},跳过此项检查")
|
|
||||||
continue
|
|
||||||
hs_actual = hs_value[0] if isinstance(hs_value, (list, tuple)) else hs_value
|
|
||||||
logger.info(f" {coil_name} 当前值: {hs_actual}")
|
|
||||||
if hs_actual:
|
|
||||||
error_msg = (
|
|
||||||
"❌ 前置握手寄存器检查失败!\n"
|
|
||||||
f" {coil_name} = True (期望值: False)\n"
|
|
||||||
f" 含义: {coil_desc}\n"
|
|
||||||
f" 原因: {stuck_reason}\n"
|
|
||||||
" 建议: 检查上次运行是否意外中断,手动将该寄存器置为False后重试"
|
|
||||||
)
|
|
||||||
logger.error(error_msg)
|
|
||||||
raise RuntimeError(error_msg)
|
|
||||||
logger.info(f" ✓ {coil_name} 检查通过 (值为False)")
|
|
||||||
except RuntimeError:
|
|
||||||
raise
|
|
||||||
except Exception as hs_e:
|
|
||||||
logger.warning(f" ⚠ 检查 {coil_name} 时发生异常: {hs_e},跳过此项")
|
|
||||||
|
|
||||||
logger.info("✓ 所有前置条件检查通过!")
|
logger.info("✓ 所有前置条件检查通过!")
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -1356,8 +1158,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
lvbodian: bool = True,
|
lvbodian: bool = True,
|
||||||
battery_pressure_mode: bool = True,
|
battery_pressure_mode: bool = True,
|
||||||
battery_clean_ignore: bool = False,
|
battery_clean_ignore: bool = False,
|
||||||
file_path: str = "/Users/sml/work",
|
file_path: str = "/Users/sml/work"
|
||||||
formulations: List[Dict] = None
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
发送瓶数+简化组装函数(适用于第二批次及后续批次)
|
发送瓶数+简化组装函数(适用于第二批次及后续批次)
|
||||||
@@ -1384,44 +1185,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
battery_pressure_mode: 是否启用压力模式
|
battery_pressure_mode: 是否启用压力模式
|
||||||
battery_clean_ignore: 是否忽略电池清洁
|
battery_clean_ignore: 是否忽略电池清洁
|
||||||
file_path: 实验记录保存路径
|
file_path: 实验记录保存路径
|
||||||
formulations: 配方信息列表(从 create_orders.mass_ratios 获取)
|
|
||||||
包含 orderCode, target_mass_ratio, real_mass_ratio 等
|
|
||||||
用于CSV数据追溯,可选参数
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: 包含组装结果的字典
|
dict: 包含组装结果的字典
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
- 第一次启动需先调用 func_pack_device_init_auto_start_combined()
|
- 第一次启动需先调用 func_pack_device_init_auto_start_combined()
|
||||||
- 后续批次直接调用此函数即可
|
- 后续批次直接调用此函数即可
|
||||||
"""
|
"""
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("开始发送瓶数+简化组装流程...")
|
logger.info("开始发送瓶数+简化组装流程...")
|
||||||
logger.info(f"电解液瓶数: {elec_num}, 每瓶电池数: {elec_use_num}")
|
logger.info(f"电解液瓶数: {elec_num}, 每瓶电池数: {elec_use_num}")
|
||||||
|
|
||||||
# 存储配方信息到设备状态(供 CSV 写入使用)
|
|
||||||
if formulations:
|
|
||||||
logger.info(f"接收到配方信息: {len(formulations)} 条")
|
|
||||||
# 将配方信息按 orderCode 索引,方便后续查找
|
|
||||||
self._formulations_map = {
|
|
||||||
f["orderCode"]: f for f in formulations
|
|
||||||
} if formulations else {}
|
|
||||||
# ✅ 新增:存储配方列表(按接收顺序),用于索引访问
|
|
||||||
self._formulations_list = formulations
|
|
||||||
else:
|
|
||||||
logger.warning("未接收到配方信息,CSV将不包含配方字段")
|
|
||||||
self._formulations_map = {}
|
|
||||||
self._formulations_list = []
|
|
||||||
|
|
||||||
# ✅ 新增:存储每瓶电池数,用于计算当前使用的瓶号
|
|
||||||
# ⚠️ 确保转换为整数(前端可能传递字符串)
|
|
||||||
self._elec_use_num = int(elec_use_num) if elec_use_num else 0
|
|
||||||
logger.info(f"已存储参数: 每瓶电池数={self._elec_use_num}, 配方数={len(self._formulations_list)}")
|
|
||||||
|
|
||||||
# ✅ 新增:软件层电池计数器(防止硬件计数器不准确)
|
|
||||||
self._software_battery_counter = 0 # 从0开始,每写入一次CSV递增
|
|
||||||
logger.info("软件层电池计数器已初始化")
|
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# 步骤1: 发送电解液瓶数(触发物料搬运)
|
# 步骤1: 发送电解液瓶数(触发物料搬运)
|
||||||
@@ -1557,8 +1331,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
data_assembly_time = self.data_assembly_time
|
data_assembly_time = self.data_assembly_time
|
||||||
data_assembly_pressure = self.data_assembly_pressure
|
data_assembly_pressure = self.data_assembly_pressure
|
||||||
data_electrolyte_volume = self.data_electrolyte_volume
|
data_electrolyte_volume = self.data_electrolyte_volume
|
||||||
data_coin_type = self.data_coin_type # 电池类型(7或8种物料)
|
data_coin_num = self.data_coin_num
|
||||||
data_battery_number = self.data_current_assembling_count # ✅ 真正的电池编号
|
|
||||||
|
|
||||||
# 处理电解液二维码 - 确保是字符串类型
|
# 处理电解液二维码 - 确保是字符串类型
|
||||||
try:
|
try:
|
||||||
@@ -1588,32 +1361,28 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
logger.debug(f"data_assembly_time: {data_assembly_time}")
|
logger.debug(f"data_assembly_time: {data_assembly_time}")
|
||||||
logger.debug(f"data_assembly_pressure: {data_assembly_pressure}")
|
logger.debug(f"data_assembly_pressure: {data_assembly_pressure}")
|
||||||
logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}")
|
logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}")
|
||||||
logger.debug(f"data_coin_type: {data_coin_type}") # 电池类型
|
logger.debug(f"data_coin_num: {data_coin_num}")
|
||||||
logger.debug(f"data_battery_number: {data_battery_number}") # ✅ 电池编号
|
|
||||||
logger.debug(f"data_electrolyte_code: {data_electrolyte_code}")
|
logger.debug(f"data_electrolyte_code: {data_electrolyte_code}")
|
||||||
logger.debug(f"data_coin_cell_code: {data_coin_cell_code}")
|
logger.debug(f"data_coin_cell_code: {data_coin_cell_code}")
|
||||||
#接收完信息后,读取完毕标志位置True
|
#接收完信息后,读取完毕标志位置True
|
||||||
finished_battery_magazine = self.deck.get_resource("成品弹夹")
|
liaopan3 = self.deck.get_resource("成品弹夹")
|
||||||
|
|
||||||
# 计算电池应该放在哪个洞,以及洞内的堆叠位置
|
|
||||||
# 成品弹夹有6个洞,每个洞可堆叠20颗电池
|
|
||||||
# 前5个洞(索引0-4)放正常电池,第6个洞(索引5)放NG电池
|
|
||||||
BATTERIES_PER_HOLE = 20
|
|
||||||
MAX_NORMAL_BATTERIES = 100 # 5个洞 × 20颗/洞
|
|
||||||
|
|
||||||
hole_index = self.coin_num_N // BATTERIES_PER_HOLE # 第几个洞(0-4为正常电池)
|
|
||||||
in_hole_position = self.coin_num_N % BATTERIES_PER_HOLE # 洞内的堆叠序号
|
|
||||||
|
|
||||||
if hole_index >= 5:
|
|
||||||
logger.error(f"电池数量超出正常容量范围: {self.coin_num_N + 1} > {MAX_NORMAL_BATTERIES}")
|
|
||||||
raise ValueError(f"成品弹夹正常洞位已满(最多{MAX_NORMAL_BATTERIES}颗),当前尝试放置第{self.coin_num_N + 1}颗")
|
|
||||||
|
|
||||||
target_hole = finished_battery_magazine.children[hole_index] # 获取目标洞
|
|
||||||
|
|
||||||
# 生成唯一的电池名称(使用时间戳确保唯一性)
|
# 生成唯一的电池名称(使用时间戳确保唯一性)
|
||||||
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||||
battery_name = f"battery_{self.coin_num_N}_{timestamp_suffix}"
|
battery_name = f"battery_{self.coin_num_N}_{timestamp_suffix}"
|
||||||
|
|
||||||
|
# 检查目标位置是否已有资源,如果有则先卸载
|
||||||
|
target_slot = liaopan3.children[self.coin_num_N]
|
||||||
|
if target_slot.children:
|
||||||
|
logger.warning(f"位置 {self.coin_num_N} 已有资源,将先卸载旧资源")
|
||||||
|
try:
|
||||||
|
# 卸载所有现有子资源
|
||||||
|
for child in list(target_slot.children):
|
||||||
|
target_slot.unassign_child_resource(child)
|
||||||
|
logger.info(f"已卸载旧资源: {child.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"卸载旧资源时出错: {e}")
|
||||||
|
|
||||||
# 创建新的电池资源
|
# 创建新的电池资源
|
||||||
battery = ElectrodeSheet(name=battery_name, size_x=14, size_y=14, size_z=2)
|
battery = ElectrodeSheet(name=battery_name, size_x=14, size_y=14, size_z=2)
|
||||||
battery._unilabos_state = {
|
battery._unilabos_state = {
|
||||||
@@ -1624,12 +1393,13 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
"electrolyte_volume": data_electrolyte_volume
|
"electrolyte_volume": data_electrolyte_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
# 将电池堆叠到目标洞中
|
# 分配新资源到目标位置
|
||||||
try:
|
try:
|
||||||
target_hole.assign_child_resource(battery, location=None)
|
target_slot.assign_child_resource(battery, location=None)
|
||||||
logger.info(f"成功放置电池 {battery_name} 到弹夹洞{hole_index}的第{in_hole_position + 1}层 (总计第{self.coin_num_N + 1}颗)")
|
logger.info(f"成功分配电池 {battery_name} 到位置 {self.coin_num_N}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"放置电池资源失败: {e}")
|
logger.error(f"分配电池资源失败: {e}")
|
||||||
|
# 如果分配失败,尝试使用更简单的方法
|
||||||
raise
|
raise
|
||||||
|
|
||||||
#print(jipian2.parent)
|
#print(jipian2.parent)
|
||||||
@@ -1660,79 +1430,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
writer.writerow([
|
writer.writerow([
|
||||||
'Time', 'open_circuit_voltage', 'pole_weight',
|
'Time', 'open_circuit_voltage', 'pole_weight',
|
||||||
'assembly_time', 'assembly_pressure', 'electrolyte_volume',
|
'assembly_time', 'assembly_pressure', 'electrolyte_volume',
|
||||||
'coin_num', 'electrolyte_code', 'coin_cell_code',
|
'coin_num', 'electrolyte_code', 'coin_cell_code'
|
||||||
'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes',
|
|
||||||
'target_mass_ratio', 'real_mass_ratio'
|
|
||||||
])
|
])
|
||||||
#立刻写入磁盘
|
#立刻写入磁盘
|
||||||
csvfile.flush()
|
csvfile.flush()
|
||||||
#开始追加电池信息
|
#开始追加电池信息
|
||||||
with open(self.csv_export_file, 'a', newline='', encoding='utf-8') as csvfile:
|
with open(self.csv_export_file, 'a', newline='', encoding='utf-8') as csvfile:
|
||||||
writer = csv.writer(csvfile)
|
writer = csv.writer(csvfile)
|
||||||
|
|
||||||
# ========== 提取配方信息 ==========
|
|
||||||
formulation_order_name = ""
|
|
||||||
prep_bottle_barcode = ""
|
|
||||||
vial_bottle_barcodes = ""
|
|
||||||
target_ratio_str = ""
|
|
||||||
real_ratio_str = ""
|
|
||||||
|
|
||||||
# 从 self._formulations_list 获取配方信息
|
|
||||||
if hasattr(self, '_formulations_list') and self._formulations_list:
|
|
||||||
# ✅ 新方案:根据电池编号和每瓶电池数计算当前瓶号
|
|
||||||
# 例如:elec_use_num=2时,电池1-2用瓶0,电池3-4用瓶1
|
|
||||||
if hasattr(self, '_elec_use_num') and self._elec_use_num:
|
|
||||||
# ⚠️ 确保转换为整数(防御性编程)
|
|
||||||
elec_use_num_int = int(self._elec_use_num) if self._elec_use_num else 1
|
|
||||||
if elec_use_num_int > 0:
|
|
||||||
current_bottle_index = (data_battery_number - 1) // elec_use_num_int
|
|
||||||
else:
|
|
||||||
current_bottle_index = 0
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"[CSV写入] 电池 {data_battery_number}: 计算瓶号索引={current_bottle_index} "
|
|
||||||
f"(每瓶{self._elec_use_num}颗电池)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 降级方案:尝试从二维码解析(仅当参数未设置时)
|
|
||||||
current_bottle_index = int(data_electrolyte_code.split('-')[-1]) if '-' in str(data_electrolyte_code) else 0
|
|
||||||
logger.debug(
|
|
||||||
f"[CSV写入] 电池 {data_battery_number}: 从二维码解析瓶号索引={current_bottle_index}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 从配方列表中获取对应配方
|
|
||||||
if 0 <= current_bottle_index < len(self._formulations_list):
|
|
||||||
formulation = self._formulations_list[current_bottle_index]
|
|
||||||
formulation_order_name = formulation.get("orderName", "")
|
|
||||||
prep_bottle_barcode = formulation.get("prep_bottle_barcode", "")
|
|
||||||
vial_bottle_barcodes = formulation.get("vial_bottle_barcodes", "")
|
|
||||||
|
|
||||||
real_ratio = formulation.get("real_mass_ratio", {})
|
|
||||||
target_ratio = formulation.get("target_mass_ratio", {})
|
|
||||||
|
|
||||||
# 将配方比例转为JSON字符串
|
|
||||||
import json
|
|
||||||
target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else ""
|
|
||||||
real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else ""
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[CSV写入] 电池 {data_battery_number}: 使用配方[{current_bottle_index}] "
|
|
||||||
f"orderName={formulation_order_name}, 配液瓶={prep_bottle_barcode}, 分液瓶={vial_bottle_barcodes}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"[CSV写入] 电池 {data_battery_number}: 瓶号索引 {current_bottle_index} "
|
|
||||||
f"超出配方列表范围 (共{len(self._formulations_list)}个配方)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug(f"[CSV写入] 电池 {data_battery_number}: 未找到配方信息数据")
|
|
||||||
|
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
timestamp, data_open_circuit_voltage, data_pole_weight,
|
timestamp, data_open_circuit_voltage, data_pole_weight,
|
||||||
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
|
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
|
||||||
data_coin_type, data_electrolyte_code, data_coin_cell_code,
|
data_coin_num, data_electrolyte_code, data_coin_cell_code
|
||||||
formulation_order_name, prep_bottle_barcode, vial_bottle_barcodes,
|
|
||||||
target_ratio_str, real_ratio_str
|
|
||||||
])
|
])
|
||||||
#立刻写入磁盘
|
#立刻写入磁盘
|
||||||
csvfile.flush()
|
csvfile.flush()
|
||||||
@@ -1959,7 +1667,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
file_path: str = "/Users/sml/work"
|
file_path: str = "/Users/sml/work"
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
简化版电池组装函数,整合了原 qiming_coin_cell_code 的参数设置和双滴模式
|
||||||
|
|
||||||
此函数是 func_allpack_cmd 的增强版本,自动处理以下配置:
|
此函数是 func_allpack_cmd 的增强版本,自动处理以下配置:
|
||||||
- 负极片和隔膜的盘数及矩阵点位
|
- 负极片和隔膜的盘数及矩阵点位
|
||||||
- 枪头盒矩阵点位
|
- 枪头盒矩阵点位
|
||||||
@@ -2213,7 +1922,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
def fun_wuliao_test(self) -> bool:
|
def fun_wuliao_test(self) -> bool:
|
||||||
#找到data_init中构建的2个物料盘
|
#找到data_init中构建的2个物料盘
|
||||||
test_battery_plate = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
|
liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
|
||||||
for i in range(16):
|
for i in range(16):
|
||||||
battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2)
|
battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2)
|
||||||
battery._unilabos_state = {
|
battery._unilabos_state = {
|
||||||
@@ -2223,7 +1932,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
"electrolyte_volume": 20.0,
|
"electrolyte_volume": 20.0,
|
||||||
"electrolyte_name": f"DP{i}"
|
"electrolyte_name": f"DP{i}"
|
||||||
}
|
}
|
||||||
test_battery_plate.children[i].assign_child_resource(battery, location=None)
|
liaopan3.children[i].assign_child_resource(battery, location=None)
|
||||||
|
|
||||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||||
"resources": [self.deck]
|
"resources": [self.deck]
|
||||||
@@ -2266,7 +1975,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
data_assembly_time = self.data_assembly_time
|
data_assembly_time = self.data_assembly_time
|
||||||
data_assembly_pressure = self.data_assembly_pressure
|
data_assembly_pressure = self.data_assembly_pressure
|
||||||
data_electrolyte_volume = self.data_electrolyte_volume
|
data_electrolyte_volume = self.data_electrolyte_volume
|
||||||
data_coin_type = self.data_coin_type # 电池类型(7或8种物料)
|
data_coin_num = self.data_coin_num
|
||||||
data_electrolyte_code = self.data_electrolyte_code
|
data_electrolyte_code = self.data_electrolyte_code
|
||||||
data_coin_cell_code = self.data_coin_cell_code
|
data_coin_cell_code = self.data_coin_cell_code
|
||||||
# 电解液瓶位置
|
# 电解液瓶位置
|
||||||
@@ -2380,7 +2089,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
writer.writerow([
|
writer.writerow([
|
||||||
timestamp, data_open_circuit_voltage, data_pole_weight,
|
timestamp, data_open_circuit_voltage, data_pole_weight,
|
||||||
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
|
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
|
||||||
data_coin_type, data_electrolyte_code, data_coin_cell_code # ✅ 已修正
|
data_coin_num, data_electrolyte_code, data_coin_cell_code
|
||||||
])
|
])
|
||||||
#立刻写入磁盘
|
#立刻写入磁盘
|
||||||
csvfile.flush()
|
csvfile.flush()
|
||||||
@@ -2431,7 +2140,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# 简单测试
|
# 简单测试
|
||||||
workstation = CoinCellAssemblyWorkstation(deck=yihua_coin_cell_deck(name="coin_cell_deck"))
|
workstation = CoinCellAssemblyWorkstation(deck=CoincellDeck(setup=True, name="coin_cell_deck"))
|
||||||
# workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False)
|
# workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False)
|
||||||
# print(f"工作站创建成功: {workstation.deck.name}")
|
# print(f"工作站创建成功: {workstation.deck.name}")
|
||||||
# print(f"料盘数量: {len(workstation.deck.children)}")
|
# print(f"料盘数量: {len(workstation.deck.children)}")
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||||
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||||
@@ -29,9 +29,7 @@ REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
|||||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||||
REG_DATA_COIN_TYPE,INT16,,,,hold_register,10018,data_coin_type
|
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||||
REG_DATA_CURRENT_ASSEMBLING_COUNT,INT16,,,,hold_register,10072,data_current_assembling_count
|
|
||||||
REG_DATA_CURRENT_COMPLETED_COUNT,INT16,,,,hold_register,10074,data_current_completed_count
|
|
||||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||||
@@ -71,75 +69,65 @@ REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
|
|||||||
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
||||||
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
||||||
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
||||||
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,??100-????
|
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
|
||||||
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,??101-??
|
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
|
||||||
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,??111-?????
|
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
|
||||||
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,??112-????????
|
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
|
||||||
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,??160-??????
|
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
|
||||||
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,??161-?????
|
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
|
||||||
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,??162-?????
|
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
|
||||||
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,??163-?????
|
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
|
||||||
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,??164-????
|
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
|
||||||
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,??165-?????
|
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
|
||||||
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,??166-????
|
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
|
||||||
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,??167-????
|
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
|
||||||
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,??168-?????
|
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
|
||||||
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,??169-??????
|
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
|
||||||
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,??201-???01??
|
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
|
||||||
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,??202-???02??
|
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
|
||||||
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,??203-???03??
|
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
|
||||||
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,??204-???04??
|
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
|
||||||
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,??205-???05??
|
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
|
||||||
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,??206-???06??
|
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
|
||||||
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,??207-???07??
|
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
|
||||||
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,??208-???08??
|
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
|
||||||
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,??209-???09??
|
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
|
||||||
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,??210-???10??
|
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
|
||||||
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,??211-???11??
|
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
|
||||||
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,??212-???12??
|
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
|
||||||
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,??213-???13??
|
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
|
||||||
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,??214-???14??
|
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
|
||||||
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,??250-??????
|
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
|
||||||
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,??251-???????
|
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
|
||||||
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,??252-?????
|
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
|
||||||
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,??256-????
|
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
|
||||||
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,??262-RB?????????
|
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
|
||||||
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,??263-RB???X?Y?Z?????
|
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制
|
||||||
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,??264-RB???????????
|
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
|
||||||
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,??265-RB???1#??????
|
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败
|
||||||
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,??266-RB???2#??????
|
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败
|
||||||
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,??267-RB???3#??????
|
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败
|
||||||
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,??268-RB???4#??????
|
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败
|
||||||
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,??269-RB?????????
|
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
|
||||||
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,??280-RB????
|
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
|
||||||
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,??290-????????
|
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
|
||||||
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,??291-????NG??
|
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
|
||||||
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,??292-???????
|
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
|
||||||
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,??310-???????????
|
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
|
||||||
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,??311-???????????
|
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
|
||||||
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,??312-???????????
|
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
|
||||||
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,??313-???????????
|
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
|
||||||
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,??340-????????????
|
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
|
||||||
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,??342-????????????
|
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
|
||||||
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,??344-??????????
|
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
|
||||||
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,??350-??????????
|
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
|
||||||
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,??352-??????????
|
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
|
||||||
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,??354-???????????
|
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
|
||||||
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,??356-???????????
|
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
|
||||||
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,??360-??????????
|
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
|
||||||
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,??362-???????????
|
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
|
||||||
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,??364-???????????
|
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
|
||||||
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,??366-?????????
|
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
|
||||||
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,??370-??????????
|
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
|
||||||
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,??151-??????????
|
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
|
||||||
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,??152-?????????
|
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
|
||||||
REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,520,10mm??????????R?
|
|
||||||
REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,522,12mm??????????R?
|
|
||||||
REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,524,16mm??????????R?
|
|
||||||
REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT,FLOAT32,,,,hold_register,526,?????????R?
|
|
||||||
REG_DATA_POSITIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,528,??????????R?
|
|
||||||
REG_DATA_FLAT_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,530,?????????R?
|
|
||||||
REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,532,??????????R?
|
|
||||||
REG_DATA_SPRING_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,534,?????????R?
|
|
||||||
REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY,FLOAT32,,,,hold_register,536,????????????R?
|
|
||||||
REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY,FLOAT32,,,,hold_register,538,????NG?????????R?
|
|
||||||
|
|||||||
|
@@ -1,88 +0,0 @@
|
|||||||
# 物料系统标准化重构方案
|
|
||||||
|
|
||||||
根据开发者的反馈,本方案旨在遵循“标准化而非绕过”的原则,对资源类(Deck、Carrier、Magazine)进行重构。核心目标是将物理结构的初始化与物料/极片的初始填充逻辑解耦,彻底解决反序列化过程中的初始化冲突。
|
|
||||||
|
|
||||||
## 拟议变更
|
|
||||||
|
|
||||||
### [参考] PRCXI9300 标准化模式
|
|
||||||
#### [参考文件] [prcxi.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/prcxi.py)
|
|
||||||
* **PRCXI9300Deck**: 演示了如何在 `serialize` 中导出 `sites` 元数据,以及如何在 `assign_child_resource` 中实现稳健的槽位匹配(支持按名称、坐标或索引匹配)。
|
|
||||||
* **PRCXI9300Container**: 演示了标准的 `load_state` 和 `serialize_state` 模式,确保业务状态(如 `Material` UUID)能正确往返序列化。
|
|
||||||
|
|
||||||
### [组件] 台面 (Decks)
|
|
||||||
#### [修改] [decks.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/bioyond/decks.py)
|
|
||||||
* 将 `BIOYOND_YB_Deck` 重命名为 **`BioyondElectrolyteDeck`**,对应工厂函数 `YB_Deck()` 重命名为 **`bioyond_electrolyte_deck()`**。
|
|
||||||
* `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` **保持不变**。
|
|
||||||
* 以上三个 Deck 的 `__init__` 中均移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
|
|
||||||
|
|
||||||
#### [修改 + 重命名] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) → `yihua_coin_cell_materials.py`
|
|
||||||
* 将 `CoincellDeck` 重命名为 **`YihuaCoinCellDeck`**,对应工厂函数 `YH_Deck()` 重命名为 **`yihua_coin_cell_deck()`**。
|
|
||||||
* 从 `YihuaCoinCellDeck.__init__` 中移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
|
|
||||||
|
|
||||||
### [组件] 容器类与弹夹 (Itemized Carriers & Magazines)
|
|
||||||
#### [修改] [magazine.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/battery/magazine.py)
|
|
||||||
* 重构 `magazine_factory`:将创建 `MagazineHolder` 几何结构(空槽位)的过程与填充 `ElectrodeSheet` 物料的过程分离。
|
|
||||||
* 确保 `MagazineHolder` 和 `Magazine` 的 `__init__` 过程中不主动创建任何内容物。
|
|
||||||
|
|
||||||
#### [修改] [warehouse.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/warehouse.py)
|
|
||||||
* 确保 `WareHouse` 类和 `warehouse_factory` 遵循相同模式:先初始化几何结构,内容物另行处理。
|
|
||||||
|
|
||||||
#### [修改] [itemized_carrier.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/itemized_carrier.py)
|
|
||||||
* 移除之前添加的 `idx is None` 兜底补丁。
|
|
||||||
* 修复命名规范,确保 `assign_child_resource` 在反序列化时能准确匹配资源。
|
|
||||||
|
|
||||||
### [组件] 状态兼容性 (State Compatibility)
|
|
||||||
#### [修改] [resource_tracker.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/resource_tracker.py)
|
|
||||||
* 在 `to_plr_resources` 方法中调用 `load_all_state` 之前,预处理 `all_states` 字典。
|
|
||||||
* 对于 `Container` 类型的资源,如果其状态中缺少 `liquid_history` 或 `pending_liquids` 等 PLR 新版本要求的键,则填充默认值(如空列表/字典),防止反序列化中断。
|
|
||||||
|
|
||||||
### [组件] 料盘 (Material Plates)
|
|
||||||
#### [修改] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py)
|
|
||||||
* 重构 `MaterialPlate`:不在 `__init__` 中直接调用 `create_ordered_items_2d`。
|
|
||||||
* 重构 `YIHUA_Electrolyte_12VialCarrier`:将其修改为标准的基类定义或在工厂方法中彻底剥离内部 12 个 `YB_pei_ye_xiao_Bottle` 的强制初始化,以防反序列化冲突。
|
|
||||||
|
|
||||||
### [组件] 跨站转运与分液瓶板 (Vial Plate Transfer)
|
|
||||||
#### [修改] [bioyond_cell_workstation.py] & [YB_YH_materials.py]
|
|
||||||
* **分析**:目前的 `bioyond_cell_workstation.py` 在执行转移时,是用 `sites=["electrolyte_buffer"]` 试图把整块 `YB_Vial_5mL_Carrier` 板转移给目标。但由于实际工艺中,配液站将分液瓶板传往扣电工站后,是由扣电工站的机械臂**逐瓶抓取**并放入内部的 `bottle_rack_6x2`(电解液缓存位),用完后再放入 `bottle_rack_6x2_2`(废液位),因此配液站的这一次“跨工位资源树转移”在逻辑上存在偏差:目标槽位不应该是装单瓶的载体 `bottle_rack`。
|
|
||||||
* **修复方案**:
|
|
||||||
1. **目标端 (Yihua 侧)**:
|
|
||||||
* 在 `YB_YH_materials.py` 中为从配液站传过来的“分液瓶板”本身设置一个接驳专用的 `PlateSlot`(或者单纯直接移到 Deck 指定坐标)。这个位置负责真正在资源树层级上合法接收配液站传过来的完整 Board。
|
|
||||||
* 重构 `YIHUA_Electrolyte_12VialCarrier`:为了防止初始化反序列化冲突,取消内部在 `__init__` 中自动填充满 12 个 `YB_pei_ye_xiao_Bottle` 实例的逻辑。`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 初始化时均应为空。
|
|
||||||
2. **转运端 (Bioyond 侧)**:
|
|
||||||
* 修改 `bioyond_cell_workstation.py` 的资源树数字转运代码,将其转移目标对应到 Yihua 侧新设立的“分液瓶板接驳区域”资源,或者干脆只更新资源树坐标位置(使其脱离 Bioyond Deck 加入 Yihua Deck),而不再强行挂载到一个无法容纳 Carrier 的 `bottle_rack_6x2` 内部。
|
|
||||||
|
|
||||||
### [组件] 依华扣电组装工站物料余量监控 (Material Monitoring)
|
|
||||||
#### [修改] 寄存器直读与前端集成
|
|
||||||
* **物理对象保留但虚化追踪**:原有的实体台面对象(如 `MaterialPlate`、`MagazineHolder` 各类型及其对应的洞位坐标)**仍然保留并使用**。保留它们是为了给机器臂提供基础的物理空间取放标定,以及作为前端页面的可视和可交互区块。
|
|
||||||
* **内部物料免追踪**:既然余量完全由寄存器接管,**我们将不再在这些弹夹或洞位内部显式生成、塞入和追踪每一个具体的极片或外壳对象 (如 `ElectrodeSheet` 等)**。这恰好与我们的重构主旨(不主动在 `__init__` 建子物料以避开反序列化冲突)完美结合,进一步极大地减轻了后台资源树对象的复杂度。
|
|
||||||
* **监控方式变更**:放弃现有的物料余量方式,直接读取依华扣电组装工站开放的寄存器地址以获取准确余量。
|
|
||||||
* **前端界面集成**:在前端界面点击负极壳、弹垫片等弹夹的 data view 时,直接读取并显示寄存器中的各自余量。
|
|
||||||
* **新增寄存器映射** (参考 `coin_cell_assembly_b.csv`):
|
|
||||||
* `10mm正极片剩余物料数量(R)`:`read hold_register 520` (REAL)
|
|
||||||
* `12mm正极片剩余物料数量(R)`:`read hold_register 522` (REAL)
|
|
||||||
* `16mm正极片剩余物料数量(R)`:`read hold_register 524` (REAL)
|
|
||||||
* `铝箔剩余物料数量(R)`:`read hold_register 526` (REAL)
|
|
||||||
* `正极壳剩余物料数量(R)`:`read hold_register 528` (REAL)
|
|
||||||
* `平垫剩余物料数量(R)`:`read hold_register 530` (REAL)
|
|
||||||
* `负极壳剩余物料数量(R)`:`read hold_register 532` (REAL)
|
|
||||||
* `弹垫剩余物料数量(R)`:`read hold_register 534` (REAL)
|
|
||||||
* `成品电池剩余可容纳数量(R)`:`read hold_register 536` (REAL)
|
|
||||||
* `成品电池NG槽剩余可容纳数量(R)`:`read hold_register 538` (REAL)
|
|
||||||
|
|
||||||
### [配置] JSON 配置文件 (Configuration Files)
|
|
||||||
#### [修改] 资源类型名称更新
|
|
||||||
* 更新以下配置文件,将其中的 `BIOYOND_YB_Deck` 替换为新的类名 **`BioyondElectrolyteDeck`**,以及将 `coin_cell_deck` 替换为 **`YihuaCoinCellDeck`**:
|
|
||||||
* `yibin_electrolyte_config.json`
|
|
||||||
* `yibin_coin_cell_only_config.json`
|
|
||||||
* `yibin_electrolyte_only_config.json`
|
|
||||||
|
|
||||||
## 验证计划
|
|
||||||
|
|
||||||
### 自动化测试
|
|
||||||
* 对重构后的类运行 `pylabrobot` 序列化/反序列化测试,确保状态能够完美恢复。
|
|
||||||
* 检查各工作站节点启动时是否仍存在 `ValueError: Resource '...' already assigned to deck` 报错。
|
|
||||||
* 检查 `resource_tracker` 中是否仍存在重复 UUID 报错。
|
|
||||||
|
|
||||||
### 手动验证
|
|
||||||
* 重启各工作站节点,验证资源树是否能根据数据库数据正确还还原。
|
|
||||||
* 验证“自动”与“手动”传输窗资源在台面上的分配是否正确。
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
# 物料系统标准化重构方案 v2(增强版)
|
|
||||||
|
|
||||||
> **基于原始方案 (`implementation_plan.md`) 的补充与细化**。
|
|
||||||
> 本文档在原方案基础上:①增加当前代码现状核查结果;②明确各任务的执行顺序与文件级改动;③新增注意事项与回归测试命令。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 核心原则(保持不变)
|
|
||||||
|
|
||||||
"**物理几何结构初始化(Deck / Carrier / Magazine 的 `__init__`)与物料内容物填充(`setup()` / `klasses` 参数)必须彻底解耦**",以消除 PLR 反序列化时的 `Resource already assigned to deck` 错误。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 当前代码现状核查(2026-03-12)
|
|
||||||
|
|
||||||
| 文件 | 计划要求 | 当前状态 | 是否完成 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `resources/bioyond/decks.py` | 重命名类;移除 `setup` 参数和 `deserialize` 补丁 | 仍是 `BIOYOND_YB_Deck`;`setup` 参数和 `deserialize` 均存在 | ❌ |
|
|
||||||
| `coin_cell_assembly/YB_YH_materials.py` | 重命名类;文件迁移;移除补丁 | 仍是 `CoincellDeck`;`setup` 参数和 `deserialize` 均存在 | ❌ |
|
|
||||||
| `resources/battery/magazine.py` | `magazine_factory` 不主动填充物料 | `MagazineHolder_6_Cathode` / `_6_Anode` / `_4_Cathode` 仍传 `klasses`,初始化时填满极片 | ❌ |
|
|
||||||
| `resources/battery/bottle_carriers.py` | `YIHUA_Electrolyte_12VialCarrier` 初始化时不填充瓶子 | 第 54-55 行仍循环填充 12 个 `YB_pei_ye_xiao_Bottle` | ❌ |
|
|
||||||
| `resources/itemized_carrier.py` | 移除 `idx is None` 兜底补丁 | 第 182-190 行仍保留该兜底逻辑 | ❌(待前置任务完成后移除) |
|
|
||||||
| `resources/resource_tracker.py` | `load_all_state` 前预填 `Container` 缺失键 | 第 616 行直接调用,无预处理 | ❌ |
|
|
||||||
| `bioyond_cell_workstation.py` | 修正跨站转运目标为合法接驳槽 | 第 1563 行仍 `sites=["electrolyte_buffer"]`,目标 UUID 为硬编码虚拟资源 | ❌ |
|
|
||||||
| `yibin_*.json` 配置文件 | 更新类名 | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌ |
|
|
||||||
| `registry/resources/bioyond/deck.yaml` | 更新类名(原计划未提及) | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌(**新增**) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 执行顺序(含依赖关系)
|
|
||||||
|
|
||||||
```
|
|
||||||
阶段 A(底层资源类)
|
|
||||||
A1. magazine.py — 移除 klasses 填充
|
|
||||||
A2. bottle_carriers.py — 移除瓶子填充
|
|
||||||
|
|
||||||
阶段 B(Deck 层)
|
|
||||||
B1. decks.py — 移除 setup 参数和 deserialize 补丁;重命名
|
|
||||||
B2. YB_YH_materials.py → 重命名;移除 CoincellDeck 的 setup 参数和 deserialize 补丁
|
|
||||||
|
|
||||||
阶段 C(状态兼容)
|
|
||||||
C1. resource_tracker.py — 预填 Container 缺失键
|
|
||||||
C2. itemized_carrier.py — 移除 idx is None 兜底补丁(B 阶段完成后)
|
|
||||||
|
|
||||||
阶段 D(跨站转运修复)
|
|
||||||
D1. YB_YH_materials.py 新增 vial_plate_dock(接驳专用槽)
|
|
||||||
D2. bioyond_cell_workstation.py 修正 transfer 目标
|
|
||||||
|
|
||||||
阶段 E(配置与注册表)
|
|
||||||
E1. yibin_*.json 更新类名
|
|
||||||
E2. registry/resources/bioyond/deck.yaml 更新类名
|
|
||||||
E3. coin_cell_assembly.py 更新导入路径(若文件重命名)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 分阶段详细说明
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 A — 底层资源类
|
|
||||||
|
|
||||||
#### A1. `unilabos/resources/battery/magazine.py`
|
|
||||||
|
|
||||||
**问题**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 在调用 `magazine_factory` 时传入 `klasses`,导致每次 `__init__` 就填满极片,反序列化时重复添加。
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
|
|
||||||
- 将三个函数中的 `klasses=[...]` 改为 `klasses=None`(与 `MagazineHolder_6_Battery` 保持一致)。
|
|
||||||
- **理由**:物料余量已由寄存器管理(见阶段 F),不需要在资源树中追踪每一个极片。
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 修改前(MagazineHolder_6_Cathode 举例)
|
|
||||||
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
|
|
||||||
|
|
||||||
# 修改后
|
|
||||||
klasses=None,
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:`magazine_factory` 中 `klasses` 参数及循环体代码保留(仍可按需在非序列化场景使用),只是各具体工厂函数不再传入。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### A2. `unilabos/resources/battery/bottle_carriers.py`
|
|
||||||
|
|
||||||
**问题**:`YIHUA_Electrolyte_12VialCarrier` 第 54-55 行在工厂函数末尾循环填充 12 个瓶子。
|
|
||||||
|
|
||||||
**修改**:删除以下两行:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 删除
|
|
||||||
for i in range(12):
|
|
||||||
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 均应初始化为空,瓶子由 Bioyond 侧实际转运后再填入。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 B — Deck 层重构
|
|
||||||
|
|
||||||
#### B1. `unilabos/resources/bioyond/decks.py`
|
|
||||||
|
|
||||||
**改动列表**:
|
|
||||||
|
|
||||||
1. **重命名** `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`
|
|
||||||
2. **重命名** `YB_Deck()` 工厂函数 → `bioyond_electrolyte_deck()`
|
|
||||||
3. **移除** `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用
|
|
||||||
4. **删除** `deserialize` 方法重写(该临时补丁在 `setup` 参数移除后自然失效,继续保留反而掩盖问题)
|
|
||||||
5. `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` 同步执行第 3、4 步
|
|
||||||
|
|
||||||
**重构后初始化模式**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class BioyondElectrolyteDeck(Deck):
|
|
||||||
def __init__(self, name: str = "YB_Deck", ...):
|
|
||||||
super().__init__(name=name, ...)
|
|
||||||
# ❌ 不调用 self.setup()
|
|
||||||
# PLR 反序列化时只会调用 __init__,然后从 children JSON 重建子资源
|
|
||||||
|
|
||||||
def setup(self) -> None:
|
|
||||||
# 完整的子资源初始化逻辑保留在这里,只由工厂函数调用
|
|
||||||
...
|
|
||||||
|
|
||||||
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
|
|
||||||
deck = BioyondElectrolyteDeck(name=name)
|
|
||||||
deck.setup() # ✅ 工厂函数负责填充
|
|
||||||
return deck
|
|
||||||
```
|
|
||||||
|
|
||||||
**同步修改**:
|
|
||||||
- `bioyond_cell_workstation.py` 第 20 行:
|
|
||||||
```python
|
|
||||||
# 修改前
|
|
||||||
from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck
|
|
||||||
# 修改后
|
|
||||||
from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck
|
|
||||||
```
|
|
||||||
- 同文件第 2440 行:`BIOYOND_YB_Deck(setup=True)` → `bioyond_electrolyte_deck(name="YB_Deck")`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### B2. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
|
|
||||||
|
|
||||||
**改动列表**:
|
|
||||||
|
|
||||||
1. **重命名** `CoincellDeck` → `YihuaCoinCellDeck`
|
|
||||||
2. **重命名** `YH_Deck()` → `yihua_coin_cell_deck()`(可保留 `YH_Deck` 作为兼容别名,日后废弃)
|
|
||||||
3. **移除** `CoincellDeck.__init__` 中 `setup: bool = False` 参数及调用
|
|
||||||
4. **删除** `CoincellDeck.deserialize` 重写方法
|
|
||||||
5. `MaterialPlate.__init__` 中移除 `fill` 参数,始终不主动调用 `create_ordered_items_2d`(当前 `fill=False` 路径已正确,只需删除 `fill=True` 分支)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 修改前(MaterialPlate.__init__ 片段)
|
|
||||||
if fill:
|
|
||||||
super().__init__(..., ordered_items=holes, ...)
|
|
||||||
else:
|
|
||||||
super().__init__(..., ordered_items=ordered_items, ...)
|
|
||||||
|
|
||||||
# 修改后(始终走 "不填充" 路径)
|
|
||||||
super().__init__(..., ordered_items=ordered_items, ...)
|
|
||||||
# holes 的创建代码整体移入独立工厂方法
|
|
||||||
```
|
|
||||||
|
|
||||||
**同步修改**:
|
|
||||||
- `coin_cell_assembly.py` 第 20 行导入:
|
|
||||||
```python
|
|
||||||
# 修改前
|
|
||||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
|
|
||||||
# 修改后
|
|
||||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck
|
|
||||||
```
|
|
||||||
- 同文件第 2245 行:`CoincellDeck(setup=True, name="coin_cell_deck")` → `yihua_coin_cell_deck(name="coin_cell_deck")`
|
|
||||||
- 文件重命名(可选):`YB_YH_materials.py` → `yihua_coin_cell_materials.py`(若重命名,所有 import 路径需全局替换)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 C — 状态兼容
|
|
||||||
|
|
||||||
#### C1. `unilabos/resources/resource_tracker.py`
|
|
||||||
|
|
||||||
**问题**:第 616 行直接调用 `plr_resource.load_all_state(all_states)`,若 `Container` 类资源的 `data` 字段缺少 `liquid_history` 或 `pending_liquids`,PLR 新版本会抛出 `KeyError`。
|
|
||||||
|
|
||||||
**修改**:在第 616 行前插入预处理:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 在 load_all_state 调用前预填缺失键
|
|
||||||
from pylabrobot.resources.container import Container as PLRContainer
|
|
||||||
for res_name, state in all_states.items():
|
|
||||||
if state and isinstance(state, dict):
|
|
||||||
# Container 类型要求这两个键存在
|
|
||||||
state.setdefault("liquid_history", [])
|
|
||||||
state.setdefault("pending_liquids", {})
|
|
||||||
|
|
||||||
plr_resource.load_all_state(all_states)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### C2. `unilabos/resources/itemized_carrier.py`
|
|
||||||
|
|
||||||
**前提**:B1、B2 阶段完成,Deck 类名与资源命名规范已对齐后再执行此步。
|
|
||||||
|
|
||||||
**修改**:删除第 182-190 行的兜底补丁:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 删除以下整个 if 块
|
|
||||||
if idx is None:
|
|
||||||
fallback_location = location if location is not None else Coordinate.zero()
|
|
||||||
super().assign_child_resource(resource, location=fallback_location, reassign=reassign)
|
|
||||||
return
|
|
||||||
```
|
|
||||||
|
|
||||||
**替代**:改为抛出带诊断信息的异常,便于后续问题排查:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if idx is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"[ItemizedCarrier] 无法为资源 '{resource.name}' 找到匹配的槽位。"
|
|
||||||
f"已知槽位:{list(self.child_locations.keys())},"
|
|
||||||
f"传入坐标:{location}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 D — 跨站转运修复
|
|
||||||
|
|
||||||
#### D1. `YB_YH_materials.py` — 新增分液瓶板接驳槽
|
|
||||||
|
|
||||||
在 `YihuaCoinCellDeck.setup()` 中,新增一个专用于接收 Bioyond 侧传来的完整分液瓶板的 `ResourceStack`(或 `PlateSlot`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 在 setup() 末尾追加
|
|
||||||
from pylabrobot.resources.resource_stack import ResourceStack
|
|
||||||
|
|
||||||
vial_plate_dock = ResourceStack(
|
|
||||||
name="electrolyte_buffer", # 保持与 bioyond_cell_workstation.py 的 sites 键一致
|
|
||||||
direction="z",
|
|
||||||
resources=[],
|
|
||||||
)
|
|
||||||
self.assign_child_resource(vial_plate_dock, Coordinate(x=1050.0, y=700.0, z=0))
|
|
||||||
```
|
|
||||||
|
|
||||||
> **说明**:槽位命名 `electrolyte_buffer` 与 `bioyond_cell_workstation.py` 现有的 `sites=["electrolyte_buffer"]` 对应,减少改动量。如改名,D2 需同步。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### D2. `bioyond_cell_workstation.py` — 修正 transfer 目标
|
|
||||||
|
|
||||||
**问题**:第 1545-1552 行创建了一个 `size=1,1,1` 的虚拟 `ResourcePLR` 并硬编码 UUID,这个对象在 YihuaCoinCellDeck 的资源树中不存在,导致转移后资源树状态混乱。
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 修改前:创建虚拟目标资源
|
|
||||||
target_resource_obj = ResourcePLR(name=target_location, size_x=1.0, ...)
|
|
||||||
target_resource_obj.unilabos_uuid = "550e8400-e29b-41d4-a716-446655440001" # 硬编码
|
|
||||||
|
|
||||||
# 修改后:通过 ROS2/设备注册表查询真实资源
|
|
||||||
# (需要从 target_device 的资源树中取出 electrolyte_buffer 的真实对象)
|
|
||||||
target_resource_obj = self._get_resource_from_device(
|
|
||||||
device_id=target_device,
|
|
||||||
resource_name=target_location
|
|
||||||
)
|
|
||||||
if target_resource_obj is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"目标设备 {target_device} 中未找到资源 '{target_location}',"
|
|
||||||
f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
> **说明**:`_get_resource_from_device` 需根据现有 ROS2 资源同步机制实现,或复用已有的 `get_plr_resource_by_name` 类似方法。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 E — 配置与注册表
|
|
||||||
|
|
||||||
#### E1. `yibin_electrolyte_config.json` / `yibin_coin_cell_only_config.json` / `yibin_electrolyte_only_config.json`
|
|
||||||
|
|
||||||
全局替换以下字符串:
|
|
||||||
|
|
||||||
| 旧值 | 新值 |
|
|
||||||
|---|---|
|
|
||||||
| `BIOYOND_YB_Deck` | `BioyondElectrolyteDeck` |
|
|
||||||
| `unilabos.resources.bioyond.decks:BIOYOND_YB_Deck` | `unilabos.resources.bioyond.decks:BioyondElectrolyteDeck` |
|
|
||||||
| `CoincellDeck` | `YihuaCoinCellDeck` |
|
|
||||||
| `unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck` | 若文件已重命名:`unilabos.devices.workstation.coin_cell_assembly.yihua_coin_cell_materials:YihuaCoinCellDeck` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### E2. `unilabos/registry/resources/bioyond/deck.yaml`(**原计划未覆盖,新增**)
|
|
||||||
|
|
||||||
当前第 25 行和第 37 行仍使用旧类名,需同步更新:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# 修改前
|
|
||||||
BIOYOND_YB_Deck:
|
|
||||||
...
|
|
||||||
CoincellDeck:
|
|
||||||
...
|
|
||||||
|
|
||||||
# 修改后
|
|
||||||
BioyondElectrolyteDeck:
|
|
||||||
...
|
|
||||||
YihuaCoinCellDeck:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 F — 物料余量监控集成(原计划第5节细化)
|
|
||||||
|
|
||||||
**目标**:弃用资源树内极片对象计数,改为直读依华扣电工站寄存器。
|
|
||||||
|
|
||||||
#### F1. `coin_cell_assembly/coin_cell_assembly.py` — 新增寄存器读取方法
|
|
||||||
|
|
||||||
参考 `coin_cell_assembly_b.csv` 中的地址,封装读取工具方法:
|
|
||||||
|
|
||||||
```python
|
|
||||||
MATERIAL_REGISTER_MAP = {
|
|
||||||
"10mm正极片": (520, "REAL"),
|
|
||||||
"12mm正极片": (522, "REAL"),
|
|
||||||
"16mm正极片": (524, "REAL"),
|
|
||||||
"铝箔": (526, "REAL"),
|
|
||||||
"正极壳": (528, "REAL"),
|
|
||||||
"平垫": (530, "REAL"),
|
|
||||||
"负极壳": (532, "REAL"),
|
|
||||||
"弹垫": (534, "REAL"),
|
|
||||||
"成品容量": (536, "REAL"),
|
|
||||||
"成品NG容量": (538, "REAL"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_material_remaining(self, material_name: str) -> float:
|
|
||||||
"""通过寄存器直读指定物料的剩余数量"""
|
|
||||||
if material_name not in MATERIAL_REGISTER_MAP:
|
|
||||||
raise KeyError(f"未知物料名称: {material_name}")
|
|
||||||
address, dtype = MATERIAL_REGISTER_MAP[material_name]
|
|
||||||
return self.read_hold_register(address, dtype) # 复用现有 Modbus 读取方法
|
|
||||||
```
|
|
||||||
|
|
||||||
#### F2. 前端 data view 集成
|
|
||||||
|
|
||||||
- 前端点击 `MagazineHolder` 类资源的 data view 时,调用后端 `get_material_remaining` 接口(而非读取 `children` 长度)。
|
|
||||||
- 具体接口路径和前端调用代码需与前端开发同步,本文档不作具体实现约定。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 验证计划(细化)
|
|
||||||
|
|
||||||
### 4.1 单元测试(自动化)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 序列化/反序列化往返测试
|
|
||||||
python -m pytest unilabos/test/ -k "serial" -v
|
|
||||||
|
|
||||||
# 特别检查以下错误消失:
|
|
||||||
# - ValueError: Resource '...' already assigned to deck
|
|
||||||
# - KeyError: 'liquid_history'
|
|
||||||
# - 重复 UUID 报错
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 集成测试(手动)
|
|
||||||
|
|
||||||
按以下顺序逐步验证,确保每步正常后再进行下一步:
|
|
||||||
|
|
||||||
1. **单独启动 `BatteryStation` 节点**,检查 `CoincellDeck`(现 `YihuaCoinCellDeck`)能否从数据库状态正确还原,无 `already assigned` 报错。
|
|
||||||
2. **单独启动 `BioyondElectrolyte` 节点**,检查 `BioyondElectrolyteDeck` 反序列化正常。
|
|
||||||
3. **同时启动两个节点**,模拟执行一次分液→扣电的完整跨站转运,确认:
|
|
||||||
- `electrolyte_buffer` 槽位正确接收分液瓶板。
|
|
||||||
- `bottle_rack_6x2` 初始为空,不出现虚拟瓶子。
|
|
||||||
4. **重启两个节点**(模拟断电恢复),确认资源树从数据库还原后,`electrolyte_buffer` 中仍持有正确的分液瓶板对象。
|
|
||||||
5. **寄存器余量读取**:手动触发 `get_material_remaining("负极壳")`,确认返回值与设备显示一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 与原计划的差异对照
|
|
||||||
|
|
||||||
| 维度 | 原计划 | 本文档新增/修订 |
|
|
||||||
|---|---|---|
|
|
||||||
| 执行顺序 | 未排序 | 明确 A→B→C→D→E→F 的依赖顺序 |
|
|
||||||
| `itemized_carrier.py` | 移除兜底补丁 | 补充:替换为带诊断信息的异常,便于排查 |
|
|
||||||
| `bottle_carriers.py` | 提及 `YIHUA_Electrolyte_12VialCarrier` 需修改 | 明确:删除第 54-55 行的瓶子填充循环 |
|
|
||||||
| `MaterialPlate` | 提及移除 `fill` 参数 | 说明保留 `fill=False` 路径;整体删除 `fill=True` 分支 |
|
|
||||||
| `deck.yaml` | 未提及 | **新增**:该注册文件也需要同步更新类名 |
|
|
||||||
| `resource_tracker.py` | 简略描述 | 提供具体的 `setdefault` 预处理代码示例 |
|
|
||||||
| 跨站转运 | 描述了问题和方向 | 细化:新增 `electrolyte_buffer` 槽位的具体名称和坐标;修正 `transfer` 目标查找方式 |
|
|
||||||
| 验证计划 | 简述目标 | 提供具体测试命令和逐步手动验证流程 |
|
|
||||||
@@ -825,6 +825,7 @@ def _extract_class_body(
|
|||||||
action_args.setdefault("placeholder_keys", {})
|
action_args.setdefault("placeholder_keys", {})
|
||||||
action_args.setdefault("always_free", False)
|
action_args.setdefault("always_free", False)
|
||||||
action_args.setdefault("is_protocol", False)
|
action_args.setdefault("is_protocol", False)
|
||||||
|
action_args.setdefault("feedback_interval", 1.0)
|
||||||
action_args.setdefault("description", "")
|
action_args.setdefault("description", "")
|
||||||
action_args.setdefault("auto_prefix", False)
|
action_args.setdefault("auto_prefix", False)
|
||||||
action_args.setdefault("parent", False)
|
action_args.setdefault("parent", False)
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ def action(
|
|||||||
auto_prefix: bool = False,
|
auto_prefix: bool = False,
|
||||||
parent: bool = False,
|
parent: bool = False,
|
||||||
node_type: Optional["NodeType"] = None,
|
node_type: Optional["NodeType"] = None,
|
||||||
|
feedback_interval: Optional[float] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
动作方法装饰器
|
动作方法装饰器
|
||||||
@@ -378,9 +379,16 @@ def action(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func: F) -> F:
|
def decorator(func: F) -> F:
|
||||||
@wraps(func)
|
import asyncio as _asyncio
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return func(*args, **kwargs)
|
if _asyncio.iscoroutinefunction(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
|
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
|
||||||
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
|
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
|
||||||
@@ -399,6 +407,8 @@ def action(
|
|||||||
"auto_prefix": auto_prefix,
|
"auto_prefix": auto_prefix,
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
}
|
}
|
||||||
|
if feedback_interval is not None:
|
||||||
|
meta["feedback_interval"] = feedback_interval
|
||||||
if node_type is not None:
|
if node_type is not None:
|
||||||
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
||||||
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -336,6 +336,47 @@ separator.chinwe:
|
|||||||
title: pump_valve参数
|
title: pump_valve参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
separation_step:
|
||||||
|
goal:
|
||||||
|
max_cycles: 0
|
||||||
|
motor_id: 5
|
||||||
|
pulses: 700
|
||||||
|
speed: 60
|
||||||
|
timeout: 300
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
description: 分液步骤 - 液位传感器与电机联动 (有液→顺时针, 无液→逆时针)
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
max_cycles:
|
||||||
|
default: 0
|
||||||
|
description: 最大循环次数 (0=无限制)
|
||||||
|
type: integer
|
||||||
|
motor_id:
|
||||||
|
default: '5'
|
||||||
|
description: 选择电机
|
||||||
|
enum:
|
||||||
|
- '4'
|
||||||
|
- '5'
|
||||||
|
title: '注: 4=搅拌, 5=旋钮'
|
||||||
|
type: string
|
||||||
|
pulses:
|
||||||
|
default: 700
|
||||||
|
description: 每次旋转脉冲数 (约1/4圈)
|
||||||
|
type: integer
|
||||||
|
speed:
|
||||||
|
default: 60
|
||||||
|
description: 电机转速 (RPM)
|
||||||
|
type: integer
|
||||||
|
timeout:
|
||||||
|
default: 300
|
||||||
|
description: 超时时间 (秒)
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- motor_id
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
wait_sensor_level:
|
wait_sensor_level:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
|
|||||||
@@ -64,12 +64,59 @@ coincellassemblyworkstation_device:
|
|||||||
properties: {}
|
properties: {}
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: fun_wuliao_test参数
|
title: fun_wuliao_test参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
auto-func_allpack_cmd:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
assembly_pressure: 4200
|
||||||
|
assembly_type: 7
|
||||||
|
elec_num: null
|
||||||
|
elec_use_num: null
|
||||||
|
elec_vol: 50
|
||||||
|
file_path: /Users/sml/work
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
assembly_pressure:
|
||||||
|
default: 4200
|
||||||
|
type: integer
|
||||||
|
assembly_type:
|
||||||
|
default: 7
|
||||||
|
type: integer
|
||||||
|
elec_num:
|
||||||
|
type: string
|
||||||
|
elec_use_num:
|
||||||
|
type: string
|
||||||
|
elec_vol:
|
||||||
|
default: 50
|
||||||
|
type: integer
|
||||||
|
file_path:
|
||||||
|
default: /Users/sml/work
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- elec_num
|
||||||
|
- elec_use_num
|
||||||
|
type: object
|
||||||
|
result:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_allpack_cmd参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
auto-func_allpack_cmd_simp:
|
auto-func_allpack_cmd_simp:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -102,7 +149,7 @@ coincellassemblyworkstation_device:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
assembly_pressure:
|
assembly_pressure:
|
||||||
default: 3200
|
default: 4200
|
||||||
description: 电池压制力(N)
|
description: 电池压制力(N)
|
||||||
type: integer
|
type: integer
|
||||||
assembly_type:
|
assembly_type:
|
||||||
@@ -118,7 +165,7 @@ coincellassemblyworkstation_device:
|
|||||||
description: 是否启用压力模式
|
description: 是否启用压力模式
|
||||||
type: boolean
|
type: boolean
|
||||||
dual_drop_first_volume:
|
dual_drop_first_volume:
|
||||||
default: 0
|
default: 25
|
||||||
description: 二次滴液第一次排液体积(μL)
|
description: 二次滴液第一次排液体积(μL)
|
||||||
type: integer
|
type: integer
|
||||||
dual_drop_mode:
|
dual_drop_mode:
|
||||||
@@ -137,7 +184,6 @@ coincellassemblyworkstation_device:
|
|||||||
description: 电解液瓶数
|
description: 电解液瓶数
|
||||||
type: string
|
type: string
|
||||||
elec_use_num:
|
elec_use_num:
|
||||||
default: 5
|
|
||||||
description: 每瓶电解液组装电池数
|
description: 每瓶电解液组装电池数
|
||||||
type: string
|
type: string
|
||||||
elec_vol:
|
elec_vol:
|
||||||
@@ -145,7 +191,7 @@ coincellassemblyworkstation_device:
|
|||||||
description: 电解液吸液量(μL)
|
description: 电解液吸液量(μL)
|
||||||
type: integer
|
type: integer
|
||||||
file_path:
|
file_path:
|
||||||
default: D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly
|
default: /Users/sml/work
|
||||||
description: 实验记录保存路径
|
description: 实验记录保存路径
|
||||||
type: string
|
type: string
|
||||||
fujipian_juzhendianwei:
|
fujipian_juzhendianwei:
|
||||||
@@ -176,7 +222,8 @@ coincellassemblyworkstation_device:
|
|||||||
- elec_num
|
- elec_num
|
||||||
- elec_use_num
|
- elec_use_num
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: object
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: func_allpack_cmd_simp参数
|
title: func_allpack_cmd_simp参数
|
||||||
@@ -265,7 +312,8 @@ coincellassemblyworkstation_device:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: func_pack_device_init_auto_start_combined参数
|
title: func_pack_device_init_auto_start_combined参数
|
||||||
@@ -307,7 +355,8 @@ coincellassemblyworkstation_device:
|
|||||||
properties: {}
|
properties: {}
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: func_pack_device_stop参数
|
title: func_pack_device_stop参数
|
||||||
@@ -332,7 +381,8 @@ coincellassemblyworkstation_device:
|
|||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: func_pack_get_msg_cmd参数
|
title: func_pack_get_msg_cmd参数
|
||||||
@@ -346,10 +396,12 @@ coincellassemblyworkstation_device:
|
|||||||
handles:
|
handles:
|
||||||
input:
|
input:
|
||||||
- data_key: bottle_num
|
- data_key: bottle_num
|
||||||
data_source: handle
|
data_source: workflow
|
||||||
data_type: integer
|
data_type: integer
|
||||||
handler_key: bottle_count
|
handler_key: bottle_count
|
||||||
|
io_type: source
|
||||||
label: 配液瓶数
|
label: 配液瓶数
|
||||||
|
required: true
|
||||||
placeholder_keys: {}
|
placeholder_keys: {}
|
||||||
result: {}
|
result: {}
|
||||||
schema:
|
schema:
|
||||||
@@ -384,7 +436,8 @@ coincellassemblyworkstation_device:
|
|||||||
properties: {}
|
properties: {}
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: func_pack_send_finished_cmd参数
|
title: func_pack_send_finished_cmd参数
|
||||||
@@ -421,7 +474,8 @@ coincellassemblyworkstation_device:
|
|||||||
- assembly_type
|
- assembly_type
|
||||||
- assembly_pressure
|
- assembly_pressure
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: func_pack_send_msg_cmd参数
|
title: func_pack_send_msg_cmd参数
|
||||||
@@ -477,15 +531,12 @@ coincellassemblyworkstation_device:
|
|||||||
handles:
|
handles:
|
||||||
input:
|
input:
|
||||||
- data_key: elec_num
|
- data_key: elec_num
|
||||||
data_source: handle
|
data_source: workflow
|
||||||
data_type: integer
|
data_type: integer
|
||||||
handler_key: bottle_count
|
handler_key: bottle_count
|
||||||
|
io_type: source
|
||||||
label: 配液瓶数
|
label: 配液瓶数
|
||||||
- data_key: formulations
|
required: true
|
||||||
data_source: handle
|
|
||||||
data_type: array
|
|
||||||
handler_key: formulations_input
|
|
||||||
label: 配方信息列表
|
|
||||||
placeholder_keys: {}
|
placeholder_keys: {}
|
||||||
result: {}
|
result: {}
|
||||||
schema:
|
schema:
|
||||||
@@ -568,7 +619,8 @@ coincellassemblyworkstation_device:
|
|||||||
- elec_num
|
- elec_num
|
||||||
- elec_use_num
|
- elec_use_num
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: object
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: func_sendbottle_allpack_multi参数
|
title: func_sendbottle_allpack_multi参数
|
||||||
@@ -620,31 +672,6 @@ coincellassemblyworkstation_device:
|
|||||||
title: modify_deck_name参数
|
title: modify_deck_name参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
auto-post_init:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
ros_node: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
ros_node:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- ros_node
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: post_init参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-qiming_coin_cell_code:
|
auto-qiming_coin_cell_code:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -692,7 +719,8 @@ coincellassemblyworkstation_device:
|
|||||||
required:
|
required:
|
||||||
- fujipian_panshu
|
- fujipian_panshu
|
||||||
type: object
|
type: object
|
||||||
result: {}
|
result:
|
||||||
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: qiming_coin_cell_code参数
|
title: qiming_coin_cell_code参数
|
||||||
@@ -700,10 +728,6 @@ coincellassemblyworkstation_device:
|
|||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||||
status_types:
|
status_types:
|
||||||
data_10mm_positive_plate_remaining: float
|
|
||||||
data_12mm_positive_plate_remaining: float
|
|
||||||
data_16mm_positive_plate_remaining: float
|
|
||||||
data_aluminum_foil_remaining: float
|
|
||||||
data_assembly_coin_cell_num: int
|
data_assembly_coin_cell_num: int
|
||||||
data_assembly_pressure: int
|
data_assembly_pressure: int
|
||||||
data_assembly_time: float
|
data_assembly_time: float
|
||||||
@@ -711,22 +735,14 @@ coincellassemblyworkstation_device:
|
|||||||
data_axis_y_pos: float
|
data_axis_y_pos: float
|
||||||
data_axis_z_pos: float
|
data_axis_z_pos: float
|
||||||
data_coin_cell_code: str
|
data_coin_cell_code: str
|
||||||
data_coin_type: int
|
data_coin_num: int
|
||||||
data_current_assembling_count: int
|
|
||||||
data_current_completed_count: int
|
|
||||||
data_electrolyte_code: str
|
data_electrolyte_code: str
|
||||||
data_electrolyte_volume: int
|
data_electrolyte_volume: int
|
||||||
data_finished_battery_ng_remaining_capacity: float
|
|
||||||
data_finished_battery_remaining_capacity: float
|
|
||||||
data_flat_washer_remaining: float
|
|
||||||
data_glove_box_o2_content: float
|
data_glove_box_o2_content: float
|
||||||
data_glove_box_pressure: float
|
data_glove_box_pressure: float
|
||||||
data_glove_box_water_content: float
|
data_glove_box_water_content: float
|
||||||
data_negative_shell_remaining: float
|
|
||||||
data_open_circuit_voltage: float
|
data_open_circuit_voltage: float
|
||||||
data_pole_weight: float
|
data_pole_weight: float
|
||||||
data_positive_shell_remaining: float
|
|
||||||
data_spring_washer_remaining: float
|
|
||||||
request_rec_msg_status: bool
|
request_rec_msg_status: bool
|
||||||
request_send_msg_status: bool
|
request_send_msg_status: bool
|
||||||
sys_mode: str
|
sys_mode: str
|
||||||
@@ -756,14 +772,6 @@ coincellassemblyworkstation_device:
|
|||||||
type: object
|
type: object
|
||||||
data:
|
data:
|
||||||
properties:
|
properties:
|
||||||
data_10mm_positive_plate_remaining:
|
|
||||||
type: number
|
|
||||||
data_12mm_positive_plate_remaining:
|
|
||||||
type: number
|
|
||||||
data_16mm_positive_plate_remaining:
|
|
||||||
type: number
|
|
||||||
data_aluminum_foil_remaining:
|
|
||||||
type: number
|
|
||||||
data_assembly_coin_cell_num:
|
data_assembly_coin_cell_num:
|
||||||
type: integer
|
type: integer
|
||||||
data_assembly_pressure:
|
data_assembly_pressure:
|
||||||
@@ -778,38 +786,22 @@ coincellassemblyworkstation_device:
|
|||||||
type: number
|
type: number
|
||||||
data_coin_cell_code:
|
data_coin_cell_code:
|
||||||
type: string
|
type: string
|
||||||
data_coin_type:
|
data_coin_num:
|
||||||
type: integer
|
|
||||||
data_current_assembling_count:
|
|
||||||
type: integer
|
|
||||||
data_current_completed_count:
|
|
||||||
type: integer
|
type: integer
|
||||||
data_electrolyte_code:
|
data_electrolyte_code:
|
||||||
type: string
|
type: string
|
||||||
data_electrolyte_volume:
|
data_electrolyte_volume:
|
||||||
type: integer
|
type: integer
|
||||||
data_finished_battery_ng_remaining_capacity:
|
|
||||||
type: number
|
|
||||||
data_finished_battery_remaining_capacity:
|
|
||||||
type: number
|
|
||||||
data_flat_washer_remaining:
|
|
||||||
type: number
|
|
||||||
data_glove_box_o2_content:
|
data_glove_box_o2_content:
|
||||||
type: number
|
type: number
|
||||||
data_glove_box_pressure:
|
data_glove_box_pressure:
|
||||||
type: number
|
type: number
|
||||||
data_glove_box_water_content:
|
data_glove_box_water_content:
|
||||||
type: number
|
type: number
|
||||||
data_negative_shell_remaining:
|
|
||||||
type: number
|
|
||||||
data_open_circuit_voltage:
|
data_open_circuit_voltage:
|
||||||
type: number
|
type: number
|
||||||
data_pole_weight:
|
data_pole_weight:
|
||||||
type: number
|
type: number
|
||||||
data_positive_shell_remaining:
|
|
||||||
type: number
|
|
||||||
data_spring_washer_remaining:
|
|
||||||
type: number
|
|
||||||
request_rec_msg_status:
|
request_rec_msg_status:
|
||||||
type: boolean
|
type: boolean
|
||||||
request_send_msg_status:
|
request_send_msg_status:
|
||||||
@@ -819,36 +811,24 @@ coincellassemblyworkstation_device:
|
|||||||
sys_status:
|
sys_status:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- sys_status
|
|
||||||
- sys_mode
|
|
||||||
- request_rec_msg_status
|
|
||||||
- request_send_msg_status
|
|
||||||
- data_assembly_coin_cell_num
|
- data_assembly_coin_cell_num
|
||||||
- data_open_circuit_voltage
|
- data_assembly_pressure
|
||||||
|
- data_assembly_time
|
||||||
- data_axis_x_pos
|
- data_axis_x_pos
|
||||||
- data_axis_y_pos
|
- data_axis_y_pos
|
||||||
- data_axis_z_pos
|
- data_axis_z_pos
|
||||||
- data_pole_weight
|
|
||||||
- data_assembly_pressure
|
|
||||||
- data_electrolyte_volume
|
|
||||||
- data_coin_type
|
|
||||||
- data_current_assembling_count
|
|
||||||
- data_current_completed_count
|
|
||||||
- data_coin_cell_code
|
- data_coin_cell_code
|
||||||
|
- data_coin_num
|
||||||
- data_electrolyte_code
|
- data_electrolyte_code
|
||||||
- data_glove_box_pressure
|
- data_electrolyte_volume
|
||||||
- data_glove_box_o2_content
|
- data_glove_box_o2_content
|
||||||
|
- data_glove_box_pressure
|
||||||
- data_glove_box_water_content
|
- data_glove_box_water_content
|
||||||
- data_10mm_positive_plate_remaining
|
- data_open_circuit_voltage
|
||||||
- data_12mm_positive_plate_remaining
|
- data_pole_weight
|
||||||
- data_16mm_positive_plate_remaining
|
- request_rec_msg_status
|
||||||
- data_aluminum_foil_remaining
|
- request_send_msg_status
|
||||||
- data_positive_shell_remaining
|
- sys_mode
|
||||||
- data_flat_washer_remaining
|
- sys_status
|
||||||
- data_negative_shell_remaining
|
|
||||||
- data_spring_washer_remaining
|
|
||||||
- data_finished_battery_remaining_capacity
|
|
||||||
- data_finished_battery_ng_remaining_capacity
|
|
||||||
type: object
|
type: object
|
||||||
registry_type: device
|
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel:
|
|||||||
type: object
|
type: object
|
||||||
model:
|
model:
|
||||||
mesh: thermo_orbitor_rs2_hotel
|
mesh: thermo_orbitor_rs2_hotel
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
|
||||||
type: device
|
type: device
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -6973,7 +6973,7 @@ liquid_handler.laiyu:
|
|||||||
properties:
|
properties:
|
||||||
channel_num:
|
channel_num:
|
||||||
default: 1
|
default: 1
|
||||||
type: string
|
type: integer
|
||||||
deck:
|
deck:
|
||||||
type: object
|
type: object
|
||||||
host:
|
host:
|
||||||
@@ -6984,10 +6984,25 @@ liquid_handler.laiyu:
|
|||||||
type: integer
|
type: integer
|
||||||
simulator:
|
simulator:
|
||||||
default: true
|
default: true
|
||||||
type: string
|
type: boolean
|
||||||
timeout:
|
timeout:
|
||||||
default: 10.0
|
default: 10.0
|
||||||
type: number
|
type: number
|
||||||
|
serial_port:
|
||||||
|
default: /dev/ttyUSB0
|
||||||
|
description: 硬件串口端口(非 simulator 模式下使用)
|
||||||
|
type: string
|
||||||
|
baudrate:
|
||||||
|
default: 115200
|
||||||
|
type: integer
|
||||||
|
pipette_address:
|
||||||
|
default: 4
|
||||||
|
description: SOPA 移液器 RS485 地址
|
||||||
|
type: integer
|
||||||
|
total_height:
|
||||||
|
default: 310
|
||||||
|
description: 龙门架总高度 (mm),用于坐标转换
|
||||||
|
type: number
|
||||||
required:
|
required:
|
||||||
- deck
|
- deck
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
286
unilabos/registry/devices/motor.yaml
Normal file
286
unilabos/registry/devices/motor.yaml
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
motor.zdt_x42:
|
||||||
|
category:
|
||||||
|
- motor
|
||||||
|
class:
|
||||||
|
action_value_mappings:
|
||||||
|
auto-enable:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
'on': true
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 使能或禁用电机。使能后电机进入锁轴状态,可接收运动指令;禁用后电机进入松轴状态。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
'on':
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: enable参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-get_position:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 获取当前电机脉冲位置。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result:
|
||||||
|
properties:
|
||||||
|
position:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: get_position参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-move_position:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
absolute: false
|
||||||
|
acceleration: 10
|
||||||
|
direction: CW
|
||||||
|
pulses: 1000
|
||||||
|
speed_rpm: 60
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 位置模式运行。控制电机移动到指定脉冲位置或相对于当前位置移动指定脉冲数。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
absolute:
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
acceleration:
|
||||||
|
default: 10
|
||||||
|
maximum: 255
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
direction:
|
||||||
|
default: CW
|
||||||
|
enum:
|
||||||
|
- CW
|
||||||
|
- CCW
|
||||||
|
type: string
|
||||||
|
pulses:
|
||||||
|
default: 1000
|
||||||
|
type: integer
|
||||||
|
speed_rpm:
|
||||||
|
default: 60
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- pulses
|
||||||
|
- speed_rpm
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: move_position参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-move_speed:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
acceleration: 10
|
||||||
|
direction: CW
|
||||||
|
speed_rpm: 60
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 速度模式运行。控制电机以指定转速和方向持续转动。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
acceleration:
|
||||||
|
default: 10
|
||||||
|
maximum: 255
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
direction:
|
||||||
|
default: CW
|
||||||
|
enum:
|
||||||
|
- CW
|
||||||
|
- CCW
|
||||||
|
type: string
|
||||||
|
speed_rpm:
|
||||||
|
default: 60
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- speed_rpm
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: move_speed参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-rotate_quarter:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
direction: CW
|
||||||
|
speed_rpm: 60
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 电机旋转 1/4 圈 (阻塞式)。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
direction:
|
||||||
|
default: CW
|
||||||
|
enum:
|
||||||
|
- CW
|
||||||
|
- CCW
|
||||||
|
type: string
|
||||||
|
speed_rpm:
|
||||||
|
default: 60
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: rotate_quarter参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-set_zero:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 将当前电机位置设为零点。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: set_zero参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-stop:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 立即停止电机运动。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: stop参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-wait_time:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
duration_s: 1.0
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 等待指定时间 (秒)。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
duration_s:
|
||||||
|
default: 1.0
|
||||||
|
minimum: 0
|
||||||
|
type: number
|
||||||
|
required:
|
||||||
|
- duration_s
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: wait_time参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
module: unilabos.devices.motor.ZDT_X42:ZDTX42Driver
|
||||||
|
status_types:
|
||||||
|
position: int
|
||||||
|
status: str
|
||||||
|
type: python
|
||||||
|
config_info: []
|
||||||
|
description: ZDT X42 闭环步进电机驱动。支持速度运行、精确位置控制、位置查询和清零功能。适用于各种需要精确运动控制的实验室自动化场景。
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema:
|
||||||
|
config:
|
||||||
|
properties:
|
||||||
|
baudrate:
|
||||||
|
default: 115200
|
||||||
|
type: integer
|
||||||
|
debug:
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
device_id:
|
||||||
|
default: 1
|
||||||
|
type: integer
|
||||||
|
port:
|
||||||
|
type: string
|
||||||
|
timeout:
|
||||||
|
default: 0.5
|
||||||
|
type: number
|
||||||
|
required:
|
||||||
|
- port
|
||||||
|
type: object
|
||||||
|
data:
|
||||||
|
properties:
|
||||||
|
position:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- position
|
||||||
|
type: object
|
||||||
|
version: 1.0.0
|
||||||
@@ -324,7 +324,7 @@ neware_battery_test_system:
|
|||||||
status_types:
|
status_types:
|
||||||
channel_status: Dict[int, Dict]
|
channel_status: Dict[int, Dict]
|
||||||
connection_info: Dict[str, str]
|
connection_info: Dict[str, str]
|
||||||
device_summary: str
|
device_summary: dict
|
||||||
status: str
|
status: str
|
||||||
total_channels: int
|
total_channels: int
|
||||||
type: python
|
type: python
|
||||||
@@ -339,18 +339,9 @@ neware_battery_test_system:
|
|||||||
type: string
|
type: string
|
||||||
ip:
|
ip:
|
||||||
type: string
|
type: string
|
||||||
machine_ids:
|
machine_id:
|
||||||
default:
|
default: 1
|
||||||
- 1
|
type: integer
|
||||||
- 2
|
|
||||||
- 3
|
|
||||||
- 4
|
|
||||||
- 5
|
|
||||||
- 6
|
|
||||||
- 86
|
|
||||||
items:
|
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
oss_prefix:
|
oss_prefix:
|
||||||
default: neware_backup
|
default: neware_backup
|
||||||
type: string
|
type: string
|
||||||
@@ -383,7 +374,7 @@ neware_battery_test_system:
|
|||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
device_summary:
|
device_summary:
|
||||||
type: string
|
type: object
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
total_channels:
|
total_channels:
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
|||||||
type: object
|
type: object
|
||||||
model:
|
model:
|
||||||
mesh: arm_slider
|
mesh: arm_slider
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
|
||||||
type: device
|
type: device
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
robotic_arm.UR:
|
robotic_arm.UR:
|
||||||
|
|||||||
148
unilabos/registry/devices/sensor.yaml
Normal file
148
unilabos/registry/devices/sensor.yaml
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
sensor.xkc_rs485:
|
||||||
|
category:
|
||||||
|
- sensor
|
||||||
|
- separator
|
||||||
|
class:
|
||||||
|
action_value_mappings:
|
||||||
|
auto-change_baudrate:
|
||||||
|
goal:
|
||||||
|
baud_code: 7
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
description: '更改通讯波特率 (设置成功后无返回,且需手动切换波特率重连)。代码表 (16进制): 05=2400, 06=4800,
|
||||||
|
07=9600, 08=14400, 09=19200, 0A=28800, 0C=57600, 0D=115200, 0E=128000,
|
||||||
|
0F=256000'
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
baud_code:
|
||||||
|
description: '波特率代码 (例如: 7 为 9600, 13 即 0x0D 为 115200)'
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- baud_code
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-change_device_id:
|
||||||
|
goal:
|
||||||
|
new_id: 1
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
description: 修改传感器的 Modbus 从站地址
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
new_id:
|
||||||
|
description: 新的从站地址 (1-254)
|
||||||
|
maximum: 254
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- new_id
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-factory_reset:
|
||||||
|
goal: {}
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
description: 恢复出厂设置 (地址重置为 01)
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-read_level:
|
||||||
|
goal: {}
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
description: 直接读取当前液位及信号强度
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-set_threshold:
|
||||||
|
goal:
|
||||||
|
threshold: 300
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
description: 设置液位判定阈值
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
threshold:
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- threshold
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-wait_for_liquid:
|
||||||
|
goal:
|
||||||
|
target_state: true
|
||||||
|
timeout: 120
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
description: 实时检测电导率(RSSI)并等待用户指定的状态
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
target_state:
|
||||||
|
default: true
|
||||||
|
description: 目标状态 (True=有液, False=无液)
|
||||||
|
type: boolean
|
||||||
|
timeout:
|
||||||
|
default: 120
|
||||||
|
description: 超时时间 (秒)
|
||||||
|
required:
|
||||||
|
- target_state
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-wait_level:
|
||||||
|
goal:
|
||||||
|
level: true
|
||||||
|
timeout: 10
|
||||||
|
handles: {}
|
||||||
|
schema:
|
||||||
|
description: 等待液位达到目标状态
|
||||||
|
properties:
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
level:
|
||||||
|
type: boolean
|
||||||
|
timeout:
|
||||||
|
type: number
|
||||||
|
required:
|
||||||
|
- level
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
module: unilabos.devices.separator.xkc_sensor:XKCSensorDriver
|
||||||
|
status_types:
|
||||||
|
level: bool
|
||||||
|
rssi: int
|
||||||
|
type: python
|
||||||
|
config_info: []
|
||||||
|
description: XKC RS485 非接触式液位传感器 (Modbus RTU)
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema:
|
||||||
|
config:
|
||||||
|
properties:
|
||||||
|
baudrate:
|
||||||
|
default: 9600
|
||||||
|
type: integer
|
||||||
|
debug:
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
device_id:
|
||||||
|
default: 1
|
||||||
|
type: integer
|
||||||
|
port:
|
||||||
|
type: string
|
||||||
|
threshold:
|
||||||
|
default: 300
|
||||||
|
type: integer
|
||||||
|
timeout:
|
||||||
|
default: 3.0
|
||||||
|
type: number
|
||||||
|
required:
|
||||||
|
- port
|
||||||
|
type: object
|
||||||
|
version: 1.0.0
|
||||||
@@ -3960,6 +3960,14 @@ virtual_separator:
|
|||||||
io_type: source
|
io_type: source
|
||||||
label: bottom_phase_out
|
label: bottom_phase_out
|
||||||
side: SOUTH
|
side: SOUTH
|
||||||
|
- data_key: top_outlet
|
||||||
|
data_source: executor
|
||||||
|
data_type: fluid
|
||||||
|
description: 上相(轻相)液体输出口
|
||||||
|
handler_key: topphaseout
|
||||||
|
io_type: source
|
||||||
|
label: top_phase_out
|
||||||
|
side: NORTH
|
||||||
- data_key: mechanical_port
|
- data_key: mechanical_port
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: mechanical
|
data_type: mechanical
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user