Compare commits
117 Commits
470d7283e4
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b61c818f7f | ||
|
|
f2c0bec02c | ||
|
|
47a29a0c2f | ||
|
|
e0394bf414 | ||
|
|
975a56415a | ||
|
|
cadbe87e3f | ||
|
|
b993c1f590 | ||
|
|
e0fae94c10 | ||
|
|
b5cd181ac1 | ||
|
|
5c047beb83 | ||
|
|
b40c087143 | ||
|
|
7f1cc3b2a5 | ||
|
|
3f160c2049 | ||
|
|
a54e7c0f23 | ||
|
|
e5015cd5e0 | ||
|
|
9c6f7c7505 | ||
|
|
514373c164 | ||
|
|
fcea02585a | ||
|
|
07cf690897 | ||
|
|
cfea27460a | ||
|
|
b7d3e980a9 | ||
|
|
f9ed6cb3fb | ||
|
|
699a0b3ce7 | ||
|
|
cf3a20ae79 | ||
|
|
cdf0652020 | ||
|
|
60073ff139 | ||
|
|
a9053b822f | ||
|
|
d238c2ab8b | ||
|
|
9a7d5c7c82 | ||
|
|
4f7d431c0b | ||
|
|
341a1b537c | ||
|
|
957fb41a6f | ||
|
|
26271bcab8 | ||
|
|
e4e4bfbe20 | ||
|
|
84a8223173 | ||
|
|
e8d1263488 | ||
|
|
380b39100d | ||
|
|
64c748d921 | ||
|
|
15ff0e9d30 | ||
|
|
f8a52860ad | ||
|
|
e30c01d54e | ||
|
|
56eb7e2ab4 | ||
|
|
23ce145f74 | ||
|
|
b0da149252 | ||
|
|
07c9e6f0fe | ||
|
|
ccec6b9d77 | ||
|
|
dadfdf3d8d | ||
|
|
37ec49f318 | ||
|
|
6bf57f18c1 | ||
|
|
400bb073d4 | ||
|
|
3f63c36505 | ||
|
|
0ae94f7f3c | ||
|
|
7eacae6442 | ||
|
|
f7d2cb4b9e | ||
|
|
bf980d7248 | ||
|
|
27c0544bfc | ||
|
|
d48e77c9ae | ||
|
|
e70a5bea66 | ||
|
|
467d75dc03 | ||
|
|
9feeb0c430 | ||
|
|
b2f26ffb28 | ||
|
|
4b0d1553e9 | ||
|
|
67ddee2ab2 | ||
|
|
1bcdad9448 | ||
|
|
039c96fe01 | ||
|
|
e1555d10a0 | ||
|
|
f2a96b2041 | ||
|
|
329349639e | ||
|
|
e4cc111523 | ||
|
|
d245ceef1b | ||
|
|
6db7fbd721 | ||
|
|
ab05b858e1 | ||
|
|
43e4c71a8e | ||
|
|
c4a3be1498 | ||
|
|
e11070315d | ||
|
|
2cf58ca452 | ||
|
|
fd73bb7dcb | ||
|
|
a02cecfd18 | ||
|
|
d6accc3f1c | ||
|
|
39dc443399 | ||
|
|
37b1fca962 | ||
|
|
216f19fb62 | ||
|
|
50ebcad9d7 | ||
|
|
ec7ca6a1fe | ||
|
|
4c8022ee95 | ||
|
|
ad21644db0 | ||
|
|
9dfd58e9af | ||
|
|
31c9f9a172 | ||
|
|
02cd8de4c5 | ||
|
|
a66603ec1c | ||
|
|
ec015e16cd | ||
|
|
965bf36e8d | ||
|
|
aacf3497e0 | ||
|
|
657f952e7a | ||
|
|
0165590290 | ||
|
|
daea1ab54d | ||
|
|
93cb307396 | ||
|
|
1c312772ae | ||
|
|
bad1db5094 | ||
|
|
f26eb69eca | ||
|
|
12c0770c92 | ||
|
|
3d2d428a96 | ||
|
|
78bf57f590 | ||
|
|
e227cddab3 | ||
|
|
f2b993643f | ||
|
|
2e14bf197c | ||
|
|
66c18c080a | ||
|
|
a1c34f138e | ||
|
|
75bb5ec553 | ||
|
|
bb95c89829 | ||
|
|
394c140830 | ||
|
|
e6d8d41183 | ||
|
|
847a300af3 | ||
|
|
a201d7c307 | ||
|
|
3433766bc5 | ||
|
|
7e9e93b29c | ||
|
|
9e1e6da505 |
62
.conda/base/recipe.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
# unilabos: Production package (depends on unilabos-env + pip unilabos)
|
||||
# For production deployment
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.18
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
target_directory: unilabos
|
||||
|
||||
build:
|
||||
python:
|
||||
entry_points:
|
||||
- unilab = unilabos.app.main:main
|
||||
script:
|
||||
- set PIP_NO_INDEX=
|
||||
- if: win
|
||||
then:
|
||||
- copy %RECIPE_DIR%\..\..\MANIFEST.in %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\..\setup.cfg %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\..\setup.py %SRC_DIR%
|
||||
- pip install %SRC_DIR%
|
||||
- if: unix
|
||||
then:
|
||||
- cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR
|
||||
- cp $RECIPE_DIR/../../setup.cfg $SRC_DIR
|
||||
- cp $RECIPE_DIR/../../setup.py $SRC_DIR
|
||||
- pip install $SRC_DIR
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python ==3.11.14
|
||||
- pip
|
||||
- setuptools
|
||||
- zstd
|
||||
- zstandard
|
||||
run:
|
||||
- zstd
|
||||
- zstandard
|
||||
- networkx
|
||||
- typing_extensions
|
||||
- websockets
|
||||
- pint
|
||||
- fastapi
|
||||
- jinja2
|
||||
- requests
|
||||
- uvicorn
|
||||
- if: not osx
|
||||
then:
|
||||
- opcua
|
||||
- pyserial
|
||||
- pandas
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.10.18
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS - Production package with minimal ROS2 dependencies"
|
||||
39
.conda/environment/recipe.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# unilabos-env: conda environment dependencies (ROS2 + conda packages)
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.18
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
requirements:
|
||||
run:
|
||||
# Python
|
||||
- zstd
|
||||
- zstandard
|
||||
- conda-forge::python ==3.11.14
|
||||
- conda-forge::opencv
|
||||
# ROS2 dependencies (from ci-check.yml)
|
||||
- robostack-staging::ros-humble-ros-core
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros-humble-control-msgs
|
||||
- robostack-staging::ros-humble-nav2-msgs
|
||||
- robostack-staging::ros-humble-cv-bridge
|
||||
- robostack-staging::ros-humble-vision-opencv
|
||||
- robostack-staging::ros-humble-tf-transformations
|
||||
- robostack-staging::ros-humble-moveit-msgs
|
||||
- robostack-staging::ros-humble-tf2-ros
|
||||
- robostack-staging::ros-humble-tf2-ros-py
|
||||
- conda-forge::transforms3d
|
||||
- conda-forge::uv
|
||||
|
||||
# UniLabOS custom messages
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS Environment - ROS2 and conda dependencies"
|
||||
42
.conda/full/recipe.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# unilabos-full: Full package with all features
|
||||
# Depends on unilabos + complete ROS2 desktop + dev tools
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.10.18
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.10.18
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
# Web UI
|
||||
- gradio
|
||||
- flask
|
||||
# Interactive development
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
- colcon-common-extensions
|
||||
# ROS2 full desktop (includes rviz2, gazebo, etc.)
|
||||
- robostack-staging::ros-humble-desktop-full
|
||||
# Navigation and motion control
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
# MoveIt motion planning
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
# Simulation
|
||||
- ros-humble-simulation
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS Full - Complete package with ROS2 Desktop, MoveIt, Navigation2, Gazebo, Jupyter"
|
||||
@@ -1,92 +0,0 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.12
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
target_directory: unilabos
|
||||
|
||||
build:
|
||||
python:
|
||||
entry_points:
|
||||
- unilab = unilabos.app.main:main
|
||||
script:
|
||||
- set PIP_NO_INDEX=
|
||||
- if: win
|
||||
then:
|
||||
- copy %RECIPE_DIR%\..\MANIFEST.in %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\setup.cfg %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\setup.py %SRC_DIR%
|
||||
- call %PYTHON% -m pip install %SRC_DIR%
|
||||
- if: unix
|
||||
then:
|
||||
- cp $RECIPE_DIR/../MANIFEST.in $SRC_DIR
|
||||
- cp $RECIPE_DIR/../setup.cfg $SRC_DIR
|
||||
- cp $RECIPE_DIR/../setup.py $SRC_DIR
|
||||
- $PYTHON -m pip install $SRC_DIR
|
||||
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python ==3.11.11
|
||||
- pip
|
||||
- setuptools
|
||||
- zstd
|
||||
- zstandard
|
||||
run:
|
||||
- conda-forge::python ==3.11.11
|
||||
- compilers
|
||||
- cmake
|
||||
- zstd
|
||||
- zstandard
|
||||
- ninja
|
||||
- if: unix
|
||||
then:
|
||||
- make
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
- numpy
|
||||
- scipy
|
||||
- pandas
|
||||
- networkx
|
||||
- matplotlib
|
||||
- pint
|
||||
- pyserial
|
||||
- pyusb
|
||||
- pylibftdi
|
||||
- pymodbus
|
||||
- python-can
|
||||
- pyvisa
|
||||
- opencv
|
||||
- pydantic
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websockets
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
- colcon-common-extensions
|
||||
- robostack-staging::ros-humble-desktop-full
|
||||
- robostack-staging::ros-humble-control-msgs
|
||||
- robostack-staging::ros-humble-sensor-msgs
|
||||
- robostack-staging::ros-humble-trajectory-msgs
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
- ros-humble-rosbridge-server
|
||||
- ros-humble-cv-bridge
|
||||
- ros-humble-tf2
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
- ros-humble-simulation
|
||||
- ros-humble-tf-transformations
|
||||
- transforms3d
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
|
||||
about:
|
||||
repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "Uni-Lab-OS"
|
||||
@@ -1,9 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM upgrade pip
|
||||
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
||||
|
||||
REM install extra deps
|
||||
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
||||
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euxo pipefail
|
||||
|
||||
# make sure pip is available
|
||||
"$PREFIX/bin/python" -m pip install --upgrade pip
|
||||
|
||||
# install extra deps
|
||||
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
||||
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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类
|
||||
- 支持定时搅拌和持续搅拌模式
|
||||
- 添加速度验证逻辑
|
||||
```
|
||||
24
.cursor/skills/add-device/SKILL.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: add-device
|
||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Walks through device category selection (thing model), communication protocol, command protocol collection, driver creation, registry YAML, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, configure device registry, or mentions 接入设备/添加设备/设备驱动/物模型.
|
||||
---
|
||||
|
||||
# 添加新设备到 Uni-Lab-OS
|
||||
|
||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南并严格遵循。
|
||||
|
||||
该指南包含:
|
||||
- 8 步完整流程(设备类别、通信协议、指令收集、接口对齐、驱动创建、注册表、图文件、验证)
|
||||
- 所有物模型代码模板(注射泵、电磁阀、蠕动泵、温控、电机等)
|
||||
- 通信协议代码片段(Serial、Modbus、TCP、HTTP、OPC UA)
|
||||
- 现有设备接口快照(用于第四步对齐,包含参数名、status_types、方法签名)
|
||||
- 常见错误检查清单
|
||||
|
||||
**Cursor 工具映射:**
|
||||
|
||||
| 指南中的操作 | Cursor 中使用的工具 |
|
||||
|---|---|
|
||||
| 向用户确认设备类别、协议等信息 | 使用 AskQuestion 工具 |
|
||||
| 搜索已有设备注册表 | 使用 Grep 在 `unilabos/registry/devices/` 中搜索 |
|
||||
| 读取用户提供的协议文档/SDK 代码 | 使用 Read 工具 |
|
||||
| 第四步对齐:查找同类设备接口 | 优先使用 Grep 搜索仓库中的最新注册表;指南中的「现有设备接口快照」作为兜底参考 |
|
||||
323
.cursor/skills/add-protocol/SKILL.md
Normal file
@@ -0,0 +1,323 @@
|
||||
---
|
||||
name: add-protocol
|
||||
description: Guide for adding new experiment protocols to Uni-Lab-OS (添加新实验操作协议). Walks through ROS Action definition, Pydantic model creation, protocol generator implementation, and registration. Use when the user wants to add a new protocol, create a compile function, implement an experiment operation, or mentions 协议/protocol/编译/compile/实验操作.
|
||||
---
|
||||
|
||||
# 添加新实验操作协议(Protocol)
|
||||
|
||||
Protocol 是对实验有意义的完整动作(如泵转移、过滤、溶解),需要多设备协同。`compile/` 中的生成函数根据设备连接图将抽象操作"编译"为设备指令序列。
|
||||
|
||||
添加一个 Protocol 需修改 **6 个文件**,按以下流程执行。
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认协议信息
|
||||
|
||||
向用户确认:
|
||||
|
||||
| 信息 | 示例 |
|
||||
|------|------|
|
||||
| 协议英文名 | `MyNewProtocol` |
|
||||
| 操作描述 | 将固体样品研磨至目标粒径 |
|
||||
| Goal 参数(必需 + 可选) | `vessel: dict`, `time: float = 300.0` |
|
||||
| Result 字段 | `success: bool`, `message: str` |
|
||||
| 需要哪些设备协同 | 研磨器、搅拌器 |
|
||||
|
||||
---
|
||||
|
||||
## 第二步:创建 ROS Action 定义
|
||||
|
||||
路径:`unilabos_msgs/action/<ActionName>.action`
|
||||
|
||||
三段式结构(Goal / Result / Feedback),用 `---` 分隔:
|
||||
|
||||
```
|
||||
# Goal
|
||||
Resource vessel
|
||||
float64 time
|
||||
string mode
|
||||
---
|
||||
# Result
|
||||
bool success
|
||||
string return_info
|
||||
---
|
||||
# Feedback
|
||||
string status
|
||||
string current_device
|
||||
builtin_interfaces/Duration time_spent
|
||||
builtin_interfaces/Duration time_remaining
|
||||
```
|
||||
|
||||
**类型映射:**
|
||||
|
||||
| Python 类型 | ROS 类型 | 说明 |
|
||||
|------------|----------|------|
|
||||
| `dict` | `Resource` | 容器/设备引用,自定义消息类型 |
|
||||
| `float` | `float64` | |
|
||||
| `int` | `int32` | |
|
||||
| `str` | `string` | |
|
||||
| `bool` | `bool` | |
|
||||
|
||||
> `Resource` 是 `unilabos_msgs/msg/Resource.msg` 中定义的自定义消息类型。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:注册 Action 到 CMakeLists
|
||||
|
||||
在 `unilabos_msgs/CMakeLists.txt` 的 `set(action_files ...)` 块中添加:
|
||||
|
||||
```cmake
|
||||
"action/MyNewAction.action"
|
||||
```
|
||||
|
||||
> 调试时需编译:`cd unilabos_msgs && colcon build && source ./install/local_setup.sh && cd ..`
|
||||
> PR 合并后 CI/CD 自动发布,`mamba update ros-humble-unilabos-msgs` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 第四步:创建 Pydantic 模型
|
||||
|
||||
在 `unilabos/messages/__init__.py` 中添加(位于 `# Start Protocols` 和 `# End Protocols` 之间):
|
||||
|
||||
```python
|
||||
class MyNewProtocol(BaseModel):
|
||||
# === 必需参数 ===
|
||||
vessel: dict = Field(..., description="目标容器")
|
||||
|
||||
# === 可选参数 ===
|
||||
time: float = Field(300.0, description="操作时间 (秒)")
|
||||
mode: str = Field("default", description="操作模式")
|
||||
|
||||
def model_post_init(self, __context):
|
||||
"""参数验证和修正"""
|
||||
if self.time <= 0:
|
||||
self.time = 300.0
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- 参数名必须与 `.action` 文件中 Goal 字段完全一致
|
||||
- `dict` 类型对应 `.action` 中的 `Resource`
|
||||
- 将类名加入文件末尾的 `__all__` 列表
|
||||
|
||||
---
|
||||
|
||||
## 第五步:实现协议生成函数
|
||||
|
||||
路径:`unilabos/compile/<protocol_name>_protocol.py`
|
||||
|
||||
```python
|
||||
import networkx as nx
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def generate_my_new_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
time: float = 300.0,
|
||||
mode: str = "default",
|
||||
**kwargs,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将 MyNewProtocol 编译为设备动作序列。
|
||||
|
||||
Args:
|
||||
G: 设备连接图(NetworkX),节点为设备/容器,边为物理连接
|
||||
vessel: 目标容器 {"id": "reactor_1"}
|
||||
time: 操作时间(秒)
|
||||
mode: 操作模式
|
||||
|
||||
Returns:
|
||||
动作列表,每个元素为:
|
||||
- dict: 单步动作
|
||||
- list[dict]: 并行动作
|
||||
"""
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
actions = []
|
||||
|
||||
# 查找相关设备(通过图的连接关系)
|
||||
# 生成动作序列
|
||||
actions.append({
|
||||
"device_id": "target_device_id",
|
||||
"action_name": "some_action",
|
||||
"action_kwargs": {"param": "value"}
|
||||
})
|
||||
|
||||
# 等待
|
||||
actions.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": time}
|
||||
})
|
||||
|
||||
return actions
|
||||
```
|
||||
|
||||
### 动作字典格式
|
||||
|
||||
```python
|
||||
# 单步动作(发给子设备)
|
||||
{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}}
|
||||
|
||||
# 发给工作站自身
|
||||
{"device_id": "self", "action_name": "my_action", "action_kwargs": {...}}
|
||||
|
||||
# 等待
|
||||
{"action_name": "wait", "action_kwargs": {"time": 5.0}}
|
||||
|
||||
# 并行动作(列表嵌套)
|
||||
[
|
||||
{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}},
|
||||
{"device_id": "stirrer_1", "action_name": "start_stir", "action_kwargs": {"stir_speed": 300}}
|
||||
]
|
||||
```
|
||||
|
||||
### 关于 `vessel` 参数类型
|
||||
|
||||
现有协议的 `vessel` 参数类型不统一:
|
||||
- 新协议趋势:使用 `dict`(如 `{"id": "reactor_1"}`)
|
||||
- 旧协议:使用 `str`(如 `"reactor_1"`)
|
||||
- 兼容写法:`Union[str, dict]`
|
||||
|
||||
**建议新协议统一使用 `dict` 类型**,通过 `get_vessel()` 兼容两种输入。
|
||||
|
||||
### 公共工具函数(`unilabos/compile/utils/`)
|
||||
|
||||
| 函数 | 用途 |
|
||||
|------|------|
|
||||
| `get_vessel(vessel)` | 解析容器参数为 `(vessel_id, vessel_data)`,兼容 dict 和 str |
|
||||
| `find_solvent_vessel(G, solvent)` | 根据溶剂名查找容器(精确→命名规则→模糊→液体类型) |
|
||||
| `find_reagent_vessel(G, reagent)` | 根据试剂名查找容器(支持固体和液体) |
|
||||
| `find_connected_stirrer(G, vessel)` | 查找与容器相连的搅拌器 |
|
||||
| `find_solid_dispenser(G)` | 查找固体加样器 |
|
||||
|
||||
### 协议内专属查找函数
|
||||
|
||||
许多协议在自己的文件内定义了专属的 `find_*` 函数(不在 `utils/` 中)。编写新协议时,优先复用 `utils/` 中的公共函数;如需特殊查找逻辑,在协议文件内部定义即可:
|
||||
|
||||
```python
|
||||
def find_my_special_device(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与容器相关的特殊设备"""
|
||||
for node in G.nodes():
|
||||
if 'my_device_type' in G.nodes[node].get('class', '').lower():
|
||||
return node
|
||||
raise ValueError("未找到特殊设备")
|
||||
```
|
||||
|
||||
### 复用已有协议
|
||||
|
||||
复杂协议通常组合已有协议:
|
||||
|
||||
```python
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
actions.extend(generate_pump_protocol_with_rinsing(
|
||||
G, from_vessel=solvent_vessel, to_vessel=vessel, volume=volume
|
||||
))
|
||||
```
|
||||
|
||||
### 图查询模式
|
||||
|
||||
```python
|
||||
# 查找与容器相连的特定类型设备
|
||||
for neighbor in G.neighbors(vessel_id):
|
||||
node_data = G.nodes[neighbor]
|
||||
if "heater" in node_data.get("class", ""):
|
||||
heater_id = neighbor
|
||||
break
|
||||
|
||||
# 查找最短路径(泵转移)
|
||||
path = nx.shortest_path(G, source=from_vessel_id, target=to_vessel_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:注册协议生成函数
|
||||
|
||||
在 `unilabos/compile/__init__.py` 中:
|
||||
|
||||
1. 顶部添加导入:
|
||||
|
||||
```python
|
||||
from .my_new_protocol import generate_my_new_protocol
|
||||
```
|
||||
|
||||
2. 在 `action_protocol_generators` 字典中添加映射:
|
||||
|
||||
```python
|
||||
action_protocol_generators = {
|
||||
# ... 已有协议
|
||||
MyNewProtocol: generate_my_new_protocol,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第七步:配置图文件
|
||||
|
||||
在工作站的图文件中,将协议名加入 `protocol_type`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"class": "workstation",
|
||||
"config": {
|
||||
"protocol_type": ["PumpTransferProtocol", "MyNewProtocol"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第八步:验证
|
||||
|
||||
```bash
|
||||
# 1. 模块可导入
|
||||
python -c "from unilabos.messages import MyNewProtocol; print(MyNewProtocol.model_fields)"
|
||||
|
||||
# 2. 生成函数可导入
|
||||
python -c "from unilabos.compile import action_protocol_generators; print(list(action_protocol_generators.keys()))"
|
||||
|
||||
# 3. 启动测试(可选)
|
||||
unilab -g <graph>.json --complete_registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
协议接入进度:
|
||||
- [ ] 1. 确认协议名、参数、涉及设备
|
||||
- [ ] 2. 创建 .action 文件 (unilabos_msgs/action/<Name>.action)
|
||||
- [ ] 3. 注册到 CMakeLists.txt
|
||||
- [ ] 4. 创建 Pydantic 模型 (unilabos/messages/__init__.py) + 更新 __all__
|
||||
- [ ] 5. 实现生成函数 (unilabos/compile/<name>_protocol.py)
|
||||
- [ ] 6. 注册到 compile/__init__.py
|
||||
- [ ] 7. 配置图文件 protocol_type
|
||||
- [ ] 8. 验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现复杂协议时,详见 [reference.md](reference.md):协议运行时数据流、mock graph 测试模式、单位解析工具(`unit_parser.py`)、复杂协议组合模式(以 dissolve 为例)。
|
||||
|
||||
---
|
||||
|
||||
## 现有协议速查
|
||||
|
||||
| 协议 | Pydantic 类 | 生成函数 | 核心参数 |
|
||||
|------|-------------|---------|---------|
|
||||
| 泵转移 | `PumpTransferProtocol` | `generate_pump_protocol_with_rinsing` | `from_vessel, to_vessel, volume` |
|
||||
| 简单转移 | `TransferProtocol` | `generate_pump_protocol` | `from_vessel, to_vessel, volume` |
|
||||
| 加样 | `AddProtocol` | `generate_add_protocol` | `vessel, reagent, volume` |
|
||||
| 过滤 | `FilterProtocol` | `generate_filter_protocol` | `vessel, filtrate_vessel` |
|
||||
| 溶解 | `DissolveProtocol` | `generate_dissolve_protocol` | `vessel, solvent, volume` |
|
||||
| 加热/冷却 | `HeatChillProtocol` | `generate_heat_chill_protocol` | `vessel, temp, time` |
|
||||
| 搅拌 | `StirProtocol` | `generate_stir_protocol` | `vessel, time` |
|
||||
| 分离 | `SeparateProtocol` | `generate_separate_protocol` | `from_vessel, separation_vessel, solvent` |
|
||||
| 蒸发 | `EvaporateProtocol` | `generate_evaporate_protocol` | `vessel, pressure, temp, time` |
|
||||
| 清洗 | `CleanProtocol` | `generate_clean_protocol` | `vessel, solvent, volume` |
|
||||
| 离心 | `CentrifugeProtocol` | `generate_centrifuge_protocol` | `vessel, speed, time` |
|
||||
| 抽气充气 | `EvacuateAndRefillProtocol` | `generate_evacuateandrefill_protocol` | `vessel, gas` |
|
||||
207
.cursor/skills/add-protocol/reference.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# 协议高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含协议运行时数据流、测试模式、单位解析工具和复杂协议组合模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 协议运行时数据流
|
||||
|
||||
从图文件到协议执行的完整链路:
|
||||
|
||||
```
|
||||
实验图 JSON
|
||||
↓ graphio.read_node_link_json()
|
||||
physical_setup_graph (NetworkX DiGraph)
|
||||
↓ ROS2WorkstationNode._setup_protocol_names(protocol_type)
|
||||
为每个 protocol_name 创建 ActionServer
|
||||
↓ 收到 Action Goal
|
||||
_create_protocol_execute_callback()
|
||||
↓ convert_from_ros_msg_with_mapping(goal, mapping)
|
||||
protocol_kwargs (Python dict)
|
||||
↓ 向 Host 查询 Resource 类型参数的当前状态
|
||||
protocol_kwargs 更新(vessel 带上 children、data 等)
|
||||
↓ protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)
|
||||
List[Dict] 动作序列
|
||||
↓ 逐步 execute_single_action / 并行 create_task
|
||||
子设备 ActionClient 执行
|
||||
```
|
||||
|
||||
### `_setup_protocol_names` 核心逻辑
|
||||
|
||||
```python
|
||||
def _setup_protocol_names(self, protocol_type):
|
||||
if isinstance(protocol_type, str):
|
||||
self.protocol_names = [p.strip() for p in protocol_type.split(",")]
|
||||
else:
|
||||
self.protocol_names = protocol_type
|
||||
self.protocol_action_mappings = {}
|
||||
for protocol_name in self.protocol_names:
|
||||
protocol_type = globals()[protocol_name] # 从 messages 模块取 Pydantic 类
|
||||
self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type)
|
||||
```
|
||||
|
||||
### `_create_protocol_execute_callback` 关键步骤
|
||||
|
||||
1. `convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])` — ROS Goal → Python dict
|
||||
2. 对 `Resource` 类型字段,通过 `resource_get` Service 查询 Host 的最新资源状态
|
||||
3. `protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)` — 调用编译函数
|
||||
4. 遍历 steps:`dict` 串行执行,`list` 并行执行
|
||||
5. `execute_single_action` 通过 `_action_clients[device_id]` 向子设备发送 Action Goal
|
||||
6. 执行完毕后通过 `resource_update` Service 更新资源状态
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试模式
|
||||
|
||||
### 2.1 协议文件内测试函数
|
||||
|
||||
许多协议文件末尾有 `test_*` 函数,主要测试参数解析工具:
|
||||
|
||||
```python
|
||||
def test_dissolve_protocol():
|
||||
"""测试溶解协议的各种参数解析"""
|
||||
volumes = ["10 mL", "?", 10.0, "1 L", "500 μL"]
|
||||
for vol in volumes:
|
||||
result = parse_volume_input(vol)
|
||||
print(f"体积解析: {vol} → {result}mL")
|
||||
|
||||
masses = ["2.9 g", "?", 2.5, "500 mg"]
|
||||
for mass in masses:
|
||||
result = parse_mass_input(mass)
|
||||
print(f"质量解析: {mass} → {result}g")
|
||||
```
|
||||
|
||||
### 2.2 使用 mock graph 测试协议生成器
|
||||
|
||||
推荐的端到端测试模式:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import networkx as nx
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def topology_graph():
|
||||
"""创建测试拓扑图"""
|
||||
G = nx.DiGraph()
|
||||
G.add_node("flask_1", **{"class": "flask", "type": "container"})
|
||||
G.add_node("stirrer_1", **{"class": "virtual_stirrer", "type": "device"})
|
||||
G.add_edge("stirrer_1", "flask_1")
|
||||
return G
|
||||
|
||||
|
||||
def test_generate_stir_protocol(topology_graph):
|
||||
"""测试搅拌协议生成"""
|
||||
actions = generate_stir_protocol(
|
||||
G=topology_graph,
|
||||
vessel="flask_1",
|
||||
time="5 min",
|
||||
stir_speed=300.0
|
||||
)
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["device_id"] == "stirrer_1"
|
||||
```
|
||||
|
||||
**要点:**
|
||||
- 用 `nx.DiGraph()` 构建最小拓扑
|
||||
- `add_node(id, **attrs)` 设置 `class`、`type`、`data` 等
|
||||
- `add_edge(src, dst)` 建立物理连接
|
||||
- 协议内的 `find_*` 函数依赖这些节点和边
|
||||
|
||||
---
|
||||
|
||||
## 3. 单位解析工具
|
||||
|
||||
路径:`unilabos/compile/utils/unit_parser.py`
|
||||
|
||||
| 函数 | 输入 | 返回 | 默认值 |
|
||||
|------|------|------|--------|
|
||||
| `parse_volume_input(input, default_unit)` | `"100 mL"`, `"2.5 L"`, `"500 μL"`, `10.0`, `"?"` | mL (float) | 50.0 |
|
||||
| `parse_mass_input(input)` | `"19.3 g"`, `"500 mg"`, `2.5`, `"?"` | g (float) | 1.0 |
|
||||
| `parse_time_input(input)` | `"30 min"`, `"1 h"`, `"300"`, `60.0`, `"?"` | 秒 (float) | 60.0 |
|
||||
|
||||
支持的单位:
|
||||
|
||||
- **体积**: mL, L, μL/uL, milliliter, liter, microliter
|
||||
- **质量**: g, mg, kg, gram, milligram, kilogram
|
||||
- **时间**: s/sec/second, min/minute, h/hr/hour, d/day
|
||||
|
||||
特殊值 `"?"`、`"unknown"`、`"tbd"` 返回默认值。
|
||||
|
||||
---
|
||||
|
||||
## 4. 复杂协议组合模式
|
||||
|
||||
以 `dissolve_protocol` 为例,展示如何组合多个子操作:
|
||||
|
||||
### 整体流程
|
||||
|
||||
```
|
||||
1. 解析参数 (parse_volume_input, parse_mass_input, parse_time_input)
|
||||
2. 设备发现 (find_connected_heatchill, find_connected_stirrer, find_solid_dispenser)
|
||||
3. 判断溶解类型 (液体 vs 固体)
|
||||
4. 组合动作序列:
|
||||
a. heat_chill_start / start_stir (启动加热/搅拌)
|
||||
b. wait (等待温度稳定)
|
||||
c. pump_protocol_with_rinsing (液体转移, 通过 extend 拼接)
|
||||
或 add_solid (固体加样)
|
||||
d. heat_chill / stir / wait (溶解等待)
|
||||
e. heat_chill_stop (停止加热)
|
||||
```
|
||||
|
||||
### 关键代码模式
|
||||
|
||||
**设备发现 → 条件组合:**
|
||||
|
||||
```python
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
solid_dispenser_id = find_solid_dispenser(G)
|
||||
|
||||
actions = []
|
||||
|
||||
# 启动阶段
|
||||
if heatchill_id and temp > 25.0:
|
||||
actions.append({
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp}
|
||||
})
|
||||
actions.append({"action_name": "wait", "action_kwargs": {"time": 30}})
|
||||
elif stirrer_id:
|
||||
actions.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "stir_speed": stir_speed}
|
||||
})
|
||||
|
||||
# 转移阶段(复用已有协议)
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G, from_vessel=solvent_vessel, to_vessel=vessel_id, volume=volume
|
||||
)
|
||||
actions.extend(pump_actions)
|
||||
|
||||
# 等待阶段
|
||||
if heatchill_id:
|
||||
actions.append({
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp, "time": time}
|
||||
})
|
||||
else:
|
||||
actions.append({"action_name": "wait", "action_kwargs": {"time": time}})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| 协议执行回调 | `unilabos/ros/nodes/presets/workstation.py` |
|
||||
| ROS 消息映射 | `unilabos/ros/msgs/message_converter.py` |
|
||||
| 物理拓扑图 | `unilabos/resources/graphio.py` (`physical_setup_graph`) |
|
||||
| 单位解析 | `unilabos/compile/utils/unit_parser.py` |
|
||||
| 容器解析 | `unilabos/compile/utils/vessel_parser.py` |
|
||||
| 溶解协议(组合示例) | `unilabos/compile/dissolve_protocol.py` |
|
||||
371
.cursor/skills/add-resource/SKILL.md
Normal file
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: add-resource
|
||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Covers Bottle, Carrier, Deck, WareHouse definitions and registry YAML. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
||||
---
|
||||
|
||||
# 添加新物料资源
|
||||
|
||||
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认资源类型
|
||||
|
||||
向用户确认需要添加的资源类型:
|
||||
|
||||
| 类型 | 基类 | 用途 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
||||
|
||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
||||
|
||||
还需确认:
|
||||
- 资源所属的项目/场景(如 bioyond、battery、通用)
|
||||
- 尺寸参数(直径、高度、最大容积等)
|
||||
- 布局参数(行列数、间距等)
|
||||
|
||||
---
|
||||
|
||||
## 第二步:创建资源定义
|
||||
|
||||
### 文件位置
|
||||
|
||||
```
|
||||
unilabos/resources/
|
||||
├── <project>/ # 按项目分组
|
||||
│ ├── bottles.py # Bottle 工厂函数
|
||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
||||
│ ├── warehouses.py # WareHouse 工厂函数
|
||||
│ └── decks.py # Deck 类定义
|
||||
├── itemized_carrier.py # Bottle, BottleCarrier, ItemizedCarrier 基类
|
||||
├── warehouse.py # WareHouse 基类
|
||||
└── container.py # 通用容器
|
||||
```
|
||||
|
||||
### 2A. 添加 Bottle(工厂函数)
|
||||
|
||||
```python
|
||||
from unilabos.resources.itemized_carrier import Bottle
|
||||
|
||||
|
||||
def My_Reagent_Bottle(
|
||||
name: str,
|
||||
diameter: float = 70.0, # 瓶体直径 (mm)
|
||||
height: float = 120.0, # 瓶体高度 (mm)
|
||||
max_volume: float = 500000.0, # 最大容积 (μL)
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建试剂瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="My_Reagent_Bottle", # 唯一标识,用于注册表和物料映射
|
||||
)
|
||||
```
|
||||
|
||||
**Bottle 参数:**
|
||||
- `name`: 实例名称(运行时唯一)
|
||||
- `diameter`: 瓶体直径 (mm)
|
||||
- `height`: 瓶体高度 (mm)
|
||||
- `max_volume`: 最大容积 (**μL**,注意单位!500mL = 500000)
|
||||
- `barcode`: 条形码(可选)
|
||||
- `model`: 模型标识,与注册表 key 一致
|
||||
|
||||
### 2B. 添加 BottleCarrier(工厂函数)
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import ResourceHolder
|
||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||
|
||||
|
||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
||||
"""创建 3x2 六槽位载架"""
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3, # 列数
|
||||
num_items_y=2, # 行数
|
||||
dx=10.0, # X 起始偏移
|
||||
dy=10.0, # Y 起始偏移
|
||||
dz=5.0, # Z 偏移
|
||||
item_dx=42.0, # X 间距
|
||||
item_dy=35.0, # Y 间距
|
||||
size_x=20.0, # 槽位宽
|
||||
size_y=20.0, # 槽位深
|
||||
size_z=50.0, # 槽位高
|
||||
)
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=146.0, # 载架总宽
|
||||
size_y=80.0, # 载架总深
|
||||
size_z=55.0, # 载架总高
|
||||
sites=sites,
|
||||
model="My_6SlotCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
|
||||
# 预装 Bottle(可选)
|
||||
ordering = ["A01", "A02", "A03", "B01", "B02", "B03"]
|
||||
for i in range(6):
|
||||
carrier[i] = My_Reagent_Bottle(f"{ordering[i]}")
|
||||
|
||||
return carrier
|
||||
```
|
||||
|
||||
### 2C. 添加 WareHouse(工厂函数)
|
||||
|
||||
```python
|
||||
from unilabos.resources.warehouse import warehouse_factory
|
||||
|
||||
|
||||
def my_warehouse_4x4(name: str) -> "WareHouse":
|
||||
"""创建 4行x4列 堆栈仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, # 列数
|
||||
num_items_y=4, # 行数
|
||||
num_items_z=1, # 层数(通常为 1)
|
||||
dx=137.0, # X 起始偏移
|
||||
dy=96.0, # Y 起始偏移
|
||||
dz=120.0, # Z 起始偏移
|
||||
item_dx=137.0, # X 间距
|
||||
item_dy=125.0, # Y 间距
|
||||
item_dz=10.0, # Z 间距(多层时用)
|
||||
resource_size_x=127.0, # 槽位宽
|
||||
resource_size_y=85.0, # 槽位深
|
||||
resource_size_z=100.0, # 槽位高
|
||||
model="my_warehouse_4x4",
|
||||
)
|
||||
```
|
||||
|
||||
**`warehouse_factory` 参数说明:**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `num_items_x/y/z` | 列数/行数/层数 |
|
||||
| `dx, dy, dz` | 第一个槽位的起始坐标偏移 |
|
||||
| `item_dx, item_dy, item_dz` | 相邻槽位间距 |
|
||||
| `resource_size_x/y/z` | 单个槽位的物理尺寸 |
|
||||
| `col_offset` | 列命名偏移(如设 4 则从 A05 开始) |
|
||||
| `row_offset` | 行命名偏移(如设 5 则从 F 行开始) |
|
||||
| `layout` | 排序方式:`"col-major"`(列优先,默认)/ `"row-major"`(行优先) |
|
||||
| `removed_positions` | 要移除的位置索引列表 |
|
||||
|
||||
自动生成 `ResourceHolder` 槽位,命名规则为 `A01, B01, C01, D01, A02, ...`(列优先)或 `A01, A02, A03, A04, B01, ...`(行优先)。
|
||||
|
||||
### 2D. 添加 Deck(类定义)
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck, Coordinate
|
||||
|
||||
|
||||
class MyStation_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "MyStation_Deck",
|
||||
size_x: float = 2700.0,
|
||||
size_y: float = 1080.0,
|
||||
size_z: float = 1500.0,
|
||||
category: str = "deck",
|
||||
setup: bool = False,
|
||||
) -> None:
|
||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
self.warehouses = {
|
||||
"仓库A": my_warehouse_4x4("仓库A"),
|
||||
"仓库B": my_warehouse_4x4("仓库B"),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"仓库A": Coordinate(-200.0, 400.0, 0.0),
|
||||
"仓库B": Coordinate(2350.0, 400.0, 0.0),
|
||||
}
|
||||
for wh_name, wh in self.warehouses.items():
|
||||
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
||||
```
|
||||
|
||||
**Deck 要点:**
|
||||
- 继承 `pylabrobot.resources.Deck`
|
||||
- `setup()` 创建 WareHouse 并通过 `assign_child_resource` 放置到指定坐标
|
||||
- `setup` 参数控制是否在构造时自动调用 `setup()`(图文件中通过 `config.setup: true` 触发)
|
||||
|
||||
---
|
||||
|
||||
## 第三步:创建注册表 YAML
|
||||
|
||||
路径:`unilabos/registry/resources/<project>/<type>.yaml`
|
||||
|
||||
### Bottle 注册
|
||||
|
||||
```yaml
|
||||
My_Reagent_Bottle:
|
||||
category:
|
||||
- bottles
|
||||
class:
|
||||
module: unilabos.resources.my_project.bottles:My_Reagent_Bottle
|
||||
type: pylabrobot
|
||||
description: 我的试剂瓶
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
### Carrier 注册
|
||||
|
||||
```yaml
|
||||
My_6SlotCarrier:
|
||||
category:
|
||||
- bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.my_project.bottle_carriers:My_6SlotCarrier
|
||||
type: pylabrobot
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
### Deck 注册
|
||||
|
||||
```yaml
|
||||
MyStation_Deck:
|
||||
category:
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.resources.my_project.decks:MyStation_Deck
|
||||
type: pylabrobot
|
||||
description: 我的工作站 Deck
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
**注册表规则:**
|
||||
- `class.module` 格式为 `python.module.path:ClassName_or_FunctionName`
|
||||
- `class.type` 固定为 `pylabrobot`
|
||||
- Key(如 `My_Reagent_Bottle`)必须与工厂函数名 / 类名一致
|
||||
- `category` 按类型标注(`bottles`, `bottle_carriers`, `deck` 等)
|
||||
|
||||
---
|
||||
|
||||
## 第四步:在图文件中引用
|
||||
|
||||
### Deck 在工作站中的引用
|
||||
|
||||
工作站节点通过 `deck` 字段引用,Deck 作为子节点:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck"],
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_project.decks:MyStation_Deck"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"config": {"type": "MyStation_Deck", "setup": true}
|
||||
}
|
||||
```
|
||||
|
||||
### 物料类型映射(外部系统对接时)
|
||||
|
||||
如果工作站需要与外部系统同步物料,在 config 中配置 `material_type_mappings`:
|
||||
|
||||
```json
|
||||
"material_type_mappings": {
|
||||
"My_Reagent_Bottle": ["试剂瓶", "external-type-uuid"],
|
||||
"My_6SlotCarrier": ["六槽载架", "external-type-uuid"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第五步:注册 PLR 扩展(如需要)
|
||||
|
||||
如果添加了新的 Deck 类,需要在 `unilabos/resources/plr_additional_res_reg.py` 中导入,使 `find_subclass` 能发现它:
|
||||
|
||||
```python
|
||||
def register():
|
||||
from unilabos.resources.my_project.decks import MyStation_Deck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:验证
|
||||
|
||||
```bash
|
||||
# 1. 资源可导入
|
||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
||||
|
||||
# 2. Deck 可创建
|
||||
python -c "
|
||||
from unilabos.resources.my_project.decks import MyStation_Deck
|
||||
d = MyStation_Deck('test', setup=True)
|
||||
print(d.children)
|
||||
"
|
||||
|
||||
# 3. 启动测试
|
||||
unilab -g <graph>.json --complete_registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
资源接入进度:
|
||||
- [ ] 1. 确定资源类型(Bottle / Carrier / WareHouse / Deck)
|
||||
- [ ] 2. 创建资源定义(工厂函数/类)
|
||||
- [ ] 3. 创建注册表 YAML (unilabos/registry/resources/<project>/<type>.yaml)
|
||||
- [ ] 4. 在图文件中引用(如需要)
|
||||
- [ ] 5. 注册 PLR 扩展(Deck 类需要)
|
||||
- [ ] 6. 验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现复杂资源系统时,详见 [reference.md](reference.md):类继承体系完整图、序列化/反序列化流程、Bioyond 物料双向同步、非瓶类资源(ElectrodeSheet / Magazine)、仓库工厂 layout 模式。
|
||||
|
||||
---
|
||||
|
||||
## 现有资源参考
|
||||
|
||||
| 项目 | Bottles | Carriers | WareHouses | Decks |
|
||||
|------|---------|----------|------------|-------|
|
||||
| bioyond | `bioyond/bottles.py` | `bioyond/bottle_carriers.py` | `bioyond/warehouses.py`, `YB_warehouses.py` | `bioyond/decks.py` |
|
||||
| battery | — | `battery/bottle_carriers.py` | — | — |
|
||||
| 通用 | — | — | `warehouse.py` | — |
|
||||
|
||||
### 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
| 资源注册表 | `unilabos/registry/resources/` |
|
||||
| 图文件加载 | `unilabos/resources/graphio.py` |
|
||||
| 资源跟踪器 | `unilabos/resources/resource_tracker.py` |
|
||||
292
.cursor/skills/add-resource/reference.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 资源高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 类继承体系
|
||||
|
||||
```
|
||||
PyLabRobot
|
||||
├── Resource (PLR 基类)
|
||||
│ ├── Well
|
||||
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
||||
│ ├── Deck
|
||||
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
||||
│ ├── ResourceHolder → 槽位占位符
|
||||
│ └── Container
|
||||
│ └── Battery (unilabos) → 组装好的电池
|
||||
│
|
||||
├── ItemizedCarrier (unilabos, 继承 Resource)
|
||||
│ ├── BottleCarrier (unilabos) → 瓶载架
|
||||
│ └── WareHouse (unilabos) → 堆栈仓库
|
||||
│
|
||||
├── ItemizedResource (PLR)
|
||||
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
||||
│
|
||||
└── ResourceStack (PLR)
|
||||
└── Magazine (unilabos) → 子弹夹洞位
|
||||
```
|
||||
|
||||
### Bottle 类细节
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def __init__(self, name, diameter, height, max_volume,
|
||||
size_x=0.0, size_y=0.0, size_z=0.0,
|
||||
barcode=None, category="container", model=None, **kwargs):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
||||
size_y=diameter,
|
||||
size_z=height, # PLR 用 height 作为 size_z
|
||||
max_volume=max_volume,
|
||||
category=category,
|
||||
model=model,
|
||||
bottom_type="flat",
|
||||
cross_section_type="circle"
|
||||
)
|
||||
```
|
||||
|
||||
注意 `size_x = size_y = diameter`,`size_z = height`。
|
||||
|
||||
### ItemizedCarrier 核心方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
||||
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
||||
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
||||
| `capacity` | 总槽位数 |
|
||||
| `sites` | 所有槽位字典 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 序列化与反序列化
|
||||
|
||||
### PLR ↔ UniLab 转换
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
||||
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
||||
|
||||
### `from_plr_resources` 流程
|
||||
|
||||
```
|
||||
PLR Resource
|
||||
↓ build_uuid_mapping (递归生成 UUID)
|
||||
↓ resource.serialize() → dict
|
||||
↓ resource.serialize_all_state() → states
|
||||
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
||||
ResourceTreeSet
|
||||
```
|
||||
|
||||
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
||||
|
||||
### `to_plr_resources` 流程
|
||||
|
||||
```
|
||||
ResourceTreeSet
|
||||
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
||||
↓ node_to_plr_dict (转为 PLR 字典格式)
|
||||
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
||||
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
||||
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
||||
PLR Resource
|
||||
```
|
||||
|
||||
### Bottle 序列化
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
return {**data, "diameter": self.diameter, "height": self.height}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal=False):
|
||||
barcode_data = data.pop("barcode", None)
|
||||
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
||||
if barcode_data and isinstance(barcode_data, str):
|
||||
instance.barcode = barcode_data
|
||||
return instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Bioyond 物料同步
|
||||
|
||||
### 双向转换函数
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
||||
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
||||
|
||||
### `resource_bioyond_to_plr` 流程
|
||||
|
||||
```
|
||||
Bioyond 物料列表
|
||||
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
||||
↓ 对每个物料:
|
||||
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
||||
initialize_resource({"name": unique_name, "class": model})
|
||||
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
||||
↓ 处理 detail (子物料/坐标)
|
||||
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
||||
PLR 资源列表
|
||||
```
|
||||
|
||||
### `resource_plr_to_bioyond` 流程
|
||||
|
||||
```
|
||||
PLR 资源列表
|
||||
↓ 遍历每个资源:
|
||||
载架(capacity > 1): 生成 details 子物料 + 坐标
|
||||
单瓶: 直接映射
|
||||
↓ type_mapping 查找 typeId
|
||||
↓ warehouse_mapping 查找位置 UUID
|
||||
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
||||
Bioyond 物料列表
|
||||
```
|
||||
|
||||
### BioyondResourceSynchronizer
|
||||
|
||||
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
||||
|
||||
```python
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
all_data = []
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
||||
unilab_resources = resource_bioyond_to_plr(
|
||||
all_data,
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
deck=self.workstation.deck
|
||||
)
|
||||
# 更新 deck 上的资源
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 非瓶类资源
|
||||
|
||||
### ElectrodeSheet(极片)
|
||||
|
||||
路径:`unilabos/resources/battery/electrode_sheet.py`
|
||||
|
||||
```python
|
||||
class ElectrodeSheet(ResourcePLR):
|
||||
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
||||
_unilabos_state = {
|
||||
"diameter": 0.0,
|
||||
"thickness": 0.0,
|
||||
"mass": 0.0,
|
||||
"material_type": "",
|
||||
"color": "",
|
||||
"info": "",
|
||||
}
|
||||
```
|
||||
|
||||
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
||||
|
||||
### Battery(电池)
|
||||
|
||||
```python
|
||||
class Battery(Container):
|
||||
"""组装好的电池"""
|
||||
_unilabos_state = {
|
||||
"color": "",
|
||||
"electrolyte_name": "",
|
||||
"open_circuit_voltage": 0.0,
|
||||
}
|
||||
```
|
||||
|
||||
### Magazine / MagazineHolder(子弹夹)
|
||||
|
||||
```python
|
||||
class Magazine(ResourceStack):
|
||||
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
||||
# direction, max_sheets
|
||||
|
||||
class MagazineHolder(ItemizedResource):
|
||||
"""多洞位子弹夹"""
|
||||
# hole_diameter, hole_depth, max_sheets_per_hole
|
||||
```
|
||||
|
||||
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓库工厂模式参考
|
||||
|
||||
### 实际 warehouse 工厂函数示例
|
||||
|
||||
```python
|
||||
# 行优先 4x4 仓库
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="row-major", # A01,A02,A03,A04, B01,...
|
||||
)
|
||||
|
||||
# 右侧 4x4 仓库(列名偏移)
|
||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
col_offset=4, # A05,A06,A07,A08
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
# 竖向仓库(站内试剂存放)
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1, num_items_y=2, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="vertical-col-major",
|
||||
)
|
||||
|
||||
# 行偏移(F 行开始)
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, num_items_y=5, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
||||
row_offset=row_offset, # 0→A行起,5→F行起
|
||||
layout="row-major",
|
||||
)
|
||||
```
|
||||
|
||||
### layout 类型说明
|
||||
|
||||
| layout | 命名顺序 | 适用场景 |
|
||||
|--------|---------|---------|
|
||||
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
||||
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
||||
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
||||
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
||||
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
||||
| 电池资源 | `unilabos/resources/battery/` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
500
.cursor/skills/add-workstation/SKILL.md
Normal file
@@ -0,0 +1,500 @@
|
||||
---
|
||||
name: add-workstation
|
||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Walks through workstation type selection, sub-device composition, external system integration, driver creation, registry YAML, deck setup, and graph file configuration. Use when the user wants to add/integrate a new workstation, create a workstation driver, configure a station with sub-devices, set up deck and materials, or mentions 工作站/工站/station/workstation.
|
||||
---
|
||||
|
||||
# Uni-Lab-OS 工作站接入指南
|
||||
|
||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统(PLR Deck)和工作流引擎。本指南覆盖从需求分析到验证的全流程。
|
||||
|
||||
> **前置知识**:工作站接入基于 `docs/ai_guides/add_device.md` 的通用设备接入框架,但有显著差异。阅读本指南前无需先读通用指南。
|
||||
|
||||
## 第一步:确定工作站类型
|
||||
|
||||
向用户确认以下信息:
|
||||
|
||||
**Q1: 工作站的业务场景?**
|
||||
|
||||
| 类型 | 基类 | 适用场景 | 示例 |
|
||||
|------|------|----------|------|
|
||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(过滤、转移、加热等) | FilterProtocolStation |
|
||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 系统对接,有专属 API | BioyondStation |
|
||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件,无外部系统 | CoinCellAssembly |
|
||||
|
||||
**Q2: 工作站英文名称?**(如 `my_reaction_station`)
|
||||
|
||||
**Q3: 与外部系统的交互方式?**
|
||||
|
||||
| 方式 | 适用场景 | 需要的配置 |
|
||||
|------|----------|-----------|
|
||||
| 无外部系统 | Protocol 工作站、纯硬件控制 | 无 |
|
||||
| HTTP API | LIMS/MES 系统(如 Bioyond) | `api_host`, `api_key` |
|
||||
| Modbus TCP | PLC 控制 | `address`, `port` |
|
||||
| OPC UA | 工业设备 | `url` |
|
||||
|
||||
**Q4: 子设备组成?**
|
||||
- 列出所有子设备(如反应器、泵、阀、传感器等)
|
||||
- 哪些是已有设备类型?哪些需要新增?
|
||||
- 子设备之间的硬件代理关系(如泵通过串口设备通信)
|
||||
|
||||
**Q5: 物料管理需求?**
|
||||
- 是否需要 Deck(物料面板)?
|
||||
- 物料类型(plate、tip_rack、bottle 等)
|
||||
- 是否需要与外部物料系统同步?
|
||||
|
||||
---
|
||||
|
||||
## 第二步:理解工作站架构
|
||||
|
||||
工作站与普通设备的核心差异:
|
||||
|
||||
| 维度 | 普通设备 | 工作站 |
|
||||
|------|---------|--------|
|
||||
| 基类 | 无(纯 Python 类) | `WorkstationBase` 或 `ProtocolNode` |
|
||||
| ROS 节点 | `BaseROS2DeviceNode` | `ROS2WorkstationNode` |
|
||||
| 状态管理 | `self.data` 字典 | 通常不用 `self.data`,用 `@property` 直接访问 |
|
||||
| 子设备 | 无 | `children` 列表,通过 `self._children` 访问 |
|
||||
| 物料 | 无 | `self.deck`(PLR Deck) |
|
||||
| 图文件角色 | `parent: null` 或 `parent: "<station>"` | `parent: null`,含 `children` 和 `deck` |
|
||||
|
||||
### 继承体系
|
||||
|
||||
`WorkstationBase` (ABC) → `ProtocolNode` (通用协议) / `BioyondWorkstation` (→ ReactionStation, DispensingStation) / `CoinCellAssemblyWorkstation` (硬件控制)
|
||||
|
||||
### ROS 层
|
||||
|
||||
`ROS2WorkstationNode` 额外负责:初始化 children 子设备节点、为子设备创建 ActionClient、配置硬件代理、为 protocol_type 创建协议 ActionServer。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:创建驱动文件
|
||||
|
||||
文件路径:`unilabos/devices/workstation/<station_name>/<station_name>.py`
|
||||
|
||||
### 模板 A:基于外部系统的工作站
|
||||
|
||||
适用于与 LIMS/MES 等外部系统对接的场景。
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
"""工作站描述"""
|
||||
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict = None,
|
||||
deck: Optional[Deck] = None,
|
||||
protocol_type: list = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"MyWorkstation")
|
||||
|
||||
# 外部系统连接配置
|
||||
self.api_host = self.config.get("api_host", "")
|
||||
self.api_key = self.config.get("api_key", "")
|
||||
|
||||
# 工作站业务状态(不同于 self.data 模式)
|
||||
self._status = "Idle"
|
||||
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode") -> None:
|
||||
super().post_init(ros_node)
|
||||
# 在这里启动后台服务、连接监控等
|
||||
|
||||
# ============ 子设备访问 ============
|
||||
|
||||
def _get_child_device(self, device_id: str):
|
||||
"""通过 ID 获取子设备节点"""
|
||||
return self._children.get(device_id)
|
||||
|
||||
# ============ 动作方法 ============
|
||||
|
||||
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
||||
"""启动调度器"""
|
||||
return {"success": True}
|
||||
|
||||
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
||||
"""创建工单"""
|
||||
return {"success": True}
|
||||
|
||||
# ============ 属性 ============
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
return "[]"
|
||||
|
||||
@property
|
||||
def material_info(self) -> str:
|
||||
return "{}"
|
||||
```
|
||||
|
||||
### 模板 B:基于硬件控制的工作站
|
||||
|
||||
适用于直接与 PLC/硬件通信的场景。
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
class MyHardwareWorkstation(WorkstationBase):
|
||||
"""硬件控制工作站"""
|
||||
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict = None,
|
||||
deck: Optional[Deck] = None,
|
||||
address: str = "192.168.1.100",
|
||||
port: str = "502",
|
||||
debug_mode: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
self.config = config or {}
|
||||
self.address = address
|
||||
self.port = int(port)
|
||||
self.debug_mode = debug_mode
|
||||
self.logger = logging.getLogger("MyHardwareWorkstation")
|
||||
|
||||
# 初始化通信客户端
|
||||
if not debug_mode:
|
||||
from unilabos.device_comms.modbus_plc.client import ModbusTcpClient
|
||||
self.client = ModbusTcpClient(host=self.address, port=self.port)
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode") -> None:
|
||||
super().post_init(ros_node)
|
||||
|
||||
# ============ 硬件读写 ============
|
||||
|
||||
def _read_register(self, name: str):
|
||||
"""读取 Modbus 寄存器"""
|
||||
if self.debug_mode:
|
||||
return 0
|
||||
# 实际读取逻辑
|
||||
pass
|
||||
|
||||
# ============ 动作方法 ============
|
||||
|
||||
async def start_process(self, **kwargs) -> Dict[str, Any]:
|
||||
"""启动加工流程"""
|
||||
return {"success": True}
|
||||
|
||||
async def stop_process(self, **kwargs) -> Dict[str, Any]:
|
||||
"""停止加工流程"""
|
||||
return {"success": True}
|
||||
|
||||
# ============ 属性(从硬件实时读取)============
|
||||
|
||||
@property
|
||||
def sys_status(self) -> str:
|
||||
return str(self._read_register("SYS_STATUS"))
|
||||
```
|
||||
|
||||
### 模板 C:Protocol 工作站
|
||||
|
||||
适用于标准化学操作协议的场景,直接使用 `ProtocolNode`。
|
||||
|
||||
```python
|
||||
from typing import List, Optional
|
||||
from pylabrobot.resources import Resource as PLRResource
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
||||
|
||||
|
||||
class MyProtocolStation(ProtocolNode):
|
||||
"""Protocol 工作站 — 使用标准化学操作协议"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
protocol_type: List[str],
|
||||
deck: Optional[PLRResource] = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(protocol_type=protocol_type, deck=deck, *args, **kwargs)
|
||||
```
|
||||
|
||||
> Protocol 工作站通常不需要自定义驱动类,直接使用 `ProtocolNode` 并在注册表和图文件中配置 `protocol_type` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 第四步:创建子设备驱动(如需要)
|
||||
|
||||
工作站的子设备本身是独立设备。按 `docs/ai_guides/add_device.md` 的标准流程创建。
|
||||
|
||||
子设备的关键约束:
|
||||
- 在图文件中 `parent` 指向工作站 ID
|
||||
- 图文件中在工作站的 `children` 数组里列出
|
||||
- 如需硬件代理,在子设备的 `config.hardware_interface.name` 指向通信设备 ID
|
||||
|
||||
---
|
||||
|
||||
## 第五步:创建注册表 YAML
|
||||
|
||||
路径:`unilabos/registry/devices/<station_name>.yaml`
|
||||
|
||||
### 最小配置
|
||||
|
||||
```yaml
|
||||
my_workstation:
|
||||
category:
|
||||
- workstation
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_station:MyWorkstation
|
||||
type: python
|
||||
```
|
||||
|
||||
启动时 `--complete_registry` 自动补全 `status_types` 和 `action_value_mappings`。
|
||||
|
||||
### 完整配置参考
|
||||
|
||||
```yaml
|
||||
my_workstation:
|
||||
description: "我的工作站"
|
||||
version: "1.0.0"
|
||||
category:
|
||||
- workstation
|
||||
- my_category
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_station:MyWorkstation
|
||||
type: python
|
||||
status_types:
|
||||
workflow_sequence: String
|
||||
material_info: String
|
||||
action_value_mappings:
|
||||
scheduler_start:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
result:
|
||||
success: success
|
||||
create_order:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal:
|
||||
json_str: json_str
|
||||
result:
|
||||
success: success
|
||||
init_param_schema:
|
||||
config:
|
||||
type: object
|
||||
deck:
|
||||
type: object
|
||||
protocol_type:
|
||||
type: array
|
||||
```
|
||||
|
||||
### 子设备注册表
|
||||
|
||||
子设备有独立的注册表文件,需要在 `category` 中包含工作站标识:
|
||||
|
||||
```yaml
|
||||
my_reactor:
|
||||
category:
|
||||
- reactor
|
||||
- my_workstation
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_reactor:MyReactor
|
||||
type: python
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:配置 Deck 资源(如需要)
|
||||
|
||||
如果工作站有物料管理需求,需要定义 Deck 类。
|
||||
|
||||
### 使用已有 Deck 类
|
||||
|
||||
查看 `unilabos/resources/` 目录下是否有适用的 Deck 类。
|
||||
|
||||
### 创建自定义 Deck
|
||||
|
||||
在 `unilabos/resources/<category>/decks.py` 中定义:
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck
|
||||
from pylabrobot.resources.coordinate import Coordinate
|
||||
|
||||
|
||||
def MyStation_Deck(name: str = "MyStation_Deck") -> Deck:
|
||||
deck = Deck(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
|
||||
# 在 deck 上定义子资源位置(carrier、plate 等)
|
||||
return deck
|
||||
```
|
||||
|
||||
在 `unilabos/resources/<category>/` 下注册或通过注册表引用。
|
||||
|
||||
---
|
||||
|
||||
## 第七步:配置图文件
|
||||
|
||||
图文件路径:`unilabos/test/experiments/<station_name>.json`
|
||||
|
||||
### 完整结构
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"name": "my_station",
|
||||
"children": ["my_deck", "sub_device_1", "sub_device_2"],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "my_workstation",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_KEY"
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_module.decks:MyStation_Deck"
|
||||
}
|
||||
},
|
||||
"size_x": 2700.0,
|
||||
"size_y": 1080.0,
|
||||
"size_z": 1500.0,
|
||||
"protocol_type": [],
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"name": "my_deck",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "MyStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "sub_device_1",
|
||||
"name": "sub_device_1",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "device",
|
||||
"class": "sub_device_registry_name",
|
||||
"position": {"x": 100, "y": 0, "z": 0},
|
||||
"config": {},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 图文件规则
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 节点唯一标识,与 `children` 数组中的引用一致 |
|
||||
| `children` | 包含 deck ID 和所有子设备 ID |
|
||||
| `parent` | 工作站节点为 `null`;子设备/deck 指向工作站 ID |
|
||||
| `type` | 工作站和子设备为 `"device"`;deck 为 `"deck"` |
|
||||
| `class` | 对应注册表中的设备名 |
|
||||
| `deck.data._resource_child_name` | 必须与 deck 节点的 `id` 一致 |
|
||||
| `deck.data._resource_type` | Deck 工厂函数的完整 Python 路径 |
|
||||
| `protocol_type` | Protocol 工作站填入协议名列表;否则为 `[]` |
|
||||
| `config` | 传入驱动 `__init__` 的 `config` 参数 |
|
||||
|
||||
---
|
||||
|
||||
## 第八步:验证
|
||||
|
||||
```bash
|
||||
# 1. 模块可导入
|
||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
||||
|
||||
# 2. 注册表补全
|
||||
unilab -g <graph>.json --complete_registry
|
||||
|
||||
# 3. 启动测试
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现外部系统对接型工作站时,详见 [reference.md](reference.md):RPC 客户端、HTTP 回调服务、连接监控、Config 结构模式(material_type_mappings / warehouse_mapping / workflow_mappings)、ResourceSynchronizer、update_resource、工作流序列、站间物料转移、post_init 完整模式。
|
||||
|
||||
---
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.__init__` 需要 `deck` 参数
|
||||
2. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
||||
3. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
||||
4. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
5. **子设备在图文件中声明** — 不在驱动代码中创建子设备实例
|
||||
6. **`deck` 配置中的 `_resource_child_name` 必须与 deck 节点 ID 一致**
|
||||
7. **Protocol 工作站优先使用 `ProtocolNode`** — 不需要自定义类
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
工作站接入进度:
|
||||
- [ ] 1. 确定工作站类型(Protocol / 外部系统 / 硬件控制)
|
||||
- [ ] 2. 确认子设备组成和物料需求
|
||||
- [ ] 3. 创建工作站驱动 unilabos/devices/workstation/<name>/<name>.py
|
||||
- [ ] 4. 创建子设备驱动(如需要,按 add_device.md 流程)
|
||||
- [ ] 5. 创建注册表 unilabos/registry/devices/<name>.yaml
|
||||
- [ ] 6. 创建/选择 Deck 资源类(如需要)
|
||||
- [ ] 7. 配置图文件 unilabos/test/experiments/<name>.json
|
||||
- [ ] 8. 验证:可导入 + 注册表补全 + 启动测试
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 现有工作站参考
|
||||
|
||||
| 工作站 | 注册表名 | 驱动类 | 类型 |
|
||||
|--------|----------|--------|------|
|
||||
| Protocol 通用 | `workstation` | `ProtocolNode` | Protocol |
|
||||
| Bioyond 反应站 | `reaction_station.bioyond` | `BioyondReactionStation` | 外部系统 |
|
||||
| Bioyond 配液站 | `bioyond_dispensing_station` | `BioyondDispensingStation` | 外部系统 |
|
||||
| 纽扣电池组装 | `coincellassemblyworkstation_device` | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
||||
|
||||
### 参考文件路径
|
||||
|
||||
- 基类: `unilabos/devices/workstation/workstation_base.py`
|
||||
- Bioyond 基类: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||
- 反应站: `unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py`
|
||||
- 配液站: `unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py`
|
||||
- 纽扣电池: `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||
- ROS 节点: `unilabos/ros/nodes/presets/workstation.py`
|
||||
- 图文件: `unilabos/test/experiments/reaction_station_bioyond.json`, `dispensing_station_bioyond.json`
|
||||
371
.cursor/skills/add-workstation/reference.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 工作站高级模式参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
||||
Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 外部系统集成模式
|
||||
|
||||
### 1.1 RPC 客户端
|
||||
|
||||
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
||||
|
||||
```python
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
|
||||
|
||||
class MySystemRPC(BaseRequest):
|
||||
"""外部系统 RPC 客户端"""
|
||||
|
||||
def __init__(self, host: str, api_key: str):
|
||||
super().__init__(host)
|
||||
self.api_key = api_key
|
||||
|
||||
def _request(self, endpoint: str, data: dict = None) -> dict:
|
||||
return self.post(
|
||||
url=f"{self.host}/api/{endpoint}",
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": data or {},
|
||||
},
|
||||
)
|
||||
|
||||
def query_status(self) -> dict:
|
||||
return self._request("status/query")
|
||||
|
||||
def create_order(self, order_data: dict) -> dict:
|
||||
return self._request("order/create", order_data)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
||||
|
||||
### 1.2 HTTP 回调服务
|
||||
|
||||
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, config=None, deck=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
http_cfg = self.config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_cfg.get("http_service_port", 8080),
|
||||
}
|
||||
self.http_service = None
|
||||
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
```
|
||||
|
||||
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
||||
|
||||
| 端点 | 调用的工作站方法 |
|
||||
|------|-----------------|
|
||||
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
||||
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
||||
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
||||
| `/report/material_change` | `process_material_change_report(report_data)` |
|
||||
| `/report/error_handling` | `handle_external_error(error_data)` |
|
||||
|
||||
实现对应方法即可接收回调:
|
||||
|
||||
```python
|
||||
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
||||
"""处理步骤完成报告"""
|
||||
step_name = report_request.data.get("stepName")
|
||||
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
||||
"""处理订单完成报告"""
|
||||
order_code = report_request.data.get("orderCode")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
||||
|
||||
### 1.3 连接监控
|
||||
|
||||
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
||||
|
||||
```python
|
||||
class ConnectionMonitor:
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 调用外部系统接口检测连接
|
||||
self.workstation.hardware_interface.ping()
|
||||
status = "online"
|
||||
except Exception:
|
||||
status = "offline"
|
||||
time.sleep(self.check_interval)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Config 结构模式
|
||||
|
||||
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
||||
|
||||
### 2.1 外部系统连接
|
||||
|
||||
```json
|
||||
{
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 HTTP 回调服务
|
||||
|
||||
```json
|
||||
{
|
||||
"http_service_config": {
|
||||
"http_service_host": "127.0.0.1",
|
||||
"http_service_port": 8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 物料类型映射
|
||||
|
||||
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"material_type_mappings": {
|
||||
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 仓库映射
|
||||
|
||||
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"仓库名": {
|
||||
"uuid": "warehouse-uuid",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-A01",
|
||||
"A02": "site-uuid-A02"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 工作流映射
|
||||
|
||||
将内部工作流名映射到外部系统的工作流 ID。
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow_mappings": {
|
||||
"internal_workflow_name": "external-workflow-uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 物料默认参数
|
||||
|
||||
```json
|
||||
{
|
||||
"material_default_parameters": {
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 资源同步机制
|
||||
|
||||
### 3.1 ResourceSynchronizer
|
||||
|
||||
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def __init__(self, workstation, api_client):
|
||||
super().__init__(workstation)
|
||||
self.api_client = api_client
|
||||
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统拉取物料到 deck"""
|
||||
external_materials = self.api_client.list_materials()
|
||||
for material in external_materials:
|
||||
plr_resource = self._convert_to_plr(material)
|
||||
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
||||
return True
|
||||
|
||||
def sync_to_external(self, plr_resource) -> bool:
|
||||
"""将 deck 中的物料变更推送到外部系统"""
|
||||
external_data = self._convert_from_plr(plr_resource)
|
||||
self.api_client.update_material(external_data)
|
||||
return True
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统推送的物料变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
### 3.2 update_resource — 上传资源树到云端
|
||||
|
||||
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
||||
|
||||
```python
|
||||
# 在 post_init 中上传初始 deck
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 在动作方法中更新特定资源
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [updated_plate]}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 工作流序列管理
|
||||
|
||||
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._workflow_sequence = []
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
"""返回 JSON 字符串,ROS 自动发布"""
|
||||
import json
|
||||
return json.dumps(self._workflow_sequence)
|
||||
|
||||
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
||||
"""添加工作流到队列"""
|
||||
self._workflow_sequence.append({
|
||||
"name": workflow_name,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
})
|
||||
return {"success": True}
|
||||
|
||||
async def clear_workflows(self) -> Dict[str, Any]:
|
||||
"""清空工作流队列"""
|
||||
self._workflow_sequence = []
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 站间物料转移
|
||||
|
||||
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
||||
|
||||
```python
|
||||
async def transfer_materials_to_another_station(
|
||||
self,
|
||||
target_device_id: str,
|
||||
transfer_groups: list,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""将物料转移到另一个工作站"""
|
||||
target_node = self._children.get(target_device_id)
|
||||
if not target_node:
|
||||
# 通过 ROS 节点查找非子设备的目标站
|
||||
pass
|
||||
|
||||
for group in transfer_groups:
|
||||
resource = self.find_resource_by_name(group["resource_name"])
|
||||
# 从本站 deck 移除
|
||||
resource.unassign()
|
||||
# 调用目标站的接收方法
|
||||
# ...
|
||||
|
||||
return {"success": True, "transferred": len(transfer_groups)}
|
||||
```
|
||||
|
||||
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
||||
|
||||
---
|
||||
|
||||
## 6. post_init 完整模式
|
||||
|
||||
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
||||
|
||||
```python
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
|
||||
# 1. 初始化外部系统客户端(此时 config 已可用)
|
||||
self.rpc_client = MySystemRPC(
|
||||
host=self.config.get("api_host"),
|
||||
api_key=self.config.get("api_key"),
|
||||
)
|
||||
self.hardware_interface = self.rpc_client
|
||||
|
||||
# 2. 启动连接监控
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
|
||||
# 3. 启动 HTTP 回调服务
|
||||
if hasattr(self, '_http_service_config'):
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
|
||||
# 4. 上传 deck 到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 5. 初始化资源同步器(可选)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
||||
```
|
||||
381
.cursor/skills/edit-experiment-graph/SKILL.md
Normal file
@@ -0,0 +1,381 @@
|
||||
---
|
||||
name: edit-experiment-graph
|
||||
description: Guide for creating and editing experiment graph files in Uni-Lab-OS (创建/编辑实验组态图). Covers node types, link types, parent-child relationships, deck configuration, and common graph patterns. Use when the user wants to create a graph file, edit an experiment configuration, set up device topology, or mentions 图文件/graph/组态/拓扑/实验图/experiment JSON.
|
||||
---
|
||||
|
||||
# 创建/编辑实验组态图
|
||||
|
||||
实验图(Graph File)定义设备拓扑、物理连接和物料配置。系统启动时加载图文件,初始化所有设备和连接关系。
|
||||
|
||||
路径:`unilabos/test/experiments/<name>.json`
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认需求
|
||||
|
||||
向用户确认:
|
||||
|
||||
| 信息 | 说明 |
|
||||
|------|------|
|
||||
| 场景类型 | 单设备调试 / 多设备联调 / 工作站完整图 |
|
||||
| 包含的设备 | 设备 ID、注册表 class 名、配置参数 |
|
||||
| 连接关系 | 物理连接(管道)/ 通信连接(串口)/ 无连接 |
|
||||
| 父子关系 | 是否有工作站包含子设备 |
|
||||
| 物料需求 | 是否需要 Deck、容器、试剂瓶 |
|
||||
|
||||
---
|
||||
|
||||
## 第二步:JSON 顶层结构
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
> `links` 也可写作 `edges`,加载时两者等效。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:定义 Nodes
|
||||
|
||||
### 节点字段
|
||||
|
||||
| 字段 | 类型 | 必需 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `id` | string | **是** | — | 节点唯一标识,links 和 children 中引用此值 |
|
||||
| `class` | string | **是** | — | 对应注册表名(设备/资源 YAML 的 key),容器可为 `null` |
|
||||
| `name` | string | 否 | 同 `id` | 显示名称,缺省时自动用 `id` |
|
||||
| `type` | string | 否 | `"device"` | 节点类型(见下表),缺省时自动设为 `"device"` |
|
||||
| `children` | string[] | 否 | `[]` | 子节点 ID 列表 |
|
||||
| `parent` | string\|null | 否 | `null` | 父节点 ID,顶层设备为 `null` |
|
||||
| `position` | object | 否 | `{x:0,y:0,z:0}` | 空间坐标 |
|
||||
| `config` | object | 否 | `{}` | 传给驱动 `__init__` 的参数 |
|
||||
| `data` | object | 否 | `{}` | 初始运行状态 |
|
||||
| `size_x/y/z` | float | 否 | — | 节点物理尺寸(工作站节点常用) |
|
||||
|
||||
> 非标准字段(如 `api_host`)会自动移入 `config`。
|
||||
|
||||
### 节点类型
|
||||
|
||||
| `type` | 用途 | `class` 要求 |
|
||||
|--------|------|-------------|
|
||||
| `device` | 设备(默认) | 注册表中的设备名 |
|
||||
| `deck` | 工作台面 | Deck 工厂函数/类名 |
|
||||
| `container` | 容器(烧瓶、反应釜) | `null` 或具体容器类名 |
|
||||
|
||||
### 设备节点模板
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_device",
|
||||
"name": "我的设备",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "registry_device_name",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"port": "/dev/ttyUSB0",
|
||||
"baudrate": 115200
|
||||
},
|
||||
"data": {
|
||||
"status": "Idle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 容器节点模板
|
||||
|
||||
容器用于协议系统中表示试剂瓶、反应釜等,`class` 通常为 `null`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "flask_DMF",
|
||||
"name": "DMF试剂瓶",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {"x": 200, "y": 500, "z": 0},
|
||||
"config": {"max_volume": 1000.0},
|
||||
"data": {
|
||||
"liquid": [{"liquid_type": "DMF", "liquid_volume": 800.0}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deck 节点模板
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_deck",
|
||||
"name": "my_deck",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "MyStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第四步:定义 Links
|
||||
|
||||
### Link 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `source` | string | 源节点 ID |
|
||||
| `target` | string | 目标节点 ID |
|
||||
| `type` | string | `"physical"` / `"fluid"` / `"communication"` |
|
||||
| `port` | object | 端口映射 `{source_id: "port_name", target_id: "port_name"}` |
|
||||
|
||||
### 物理/流体连接
|
||||
|
||||
设备间的管道连接,协议系统用此查找路径:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "multiway_valve_1",
|
||||
"target": "flask_DMF",
|
||||
"type": "fluid",
|
||||
"port": {
|
||||
"multiway_valve_1": "2",
|
||||
"flask_DMF": "outlet"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 通信连接
|
||||
|
||||
设备间的串口/IO 通信代理,加载时自动将端口信息写入目标设备 config:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "serial_device",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_1": "port",
|
||||
"serial_device": "port"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第五步:父子关系与工作站配置
|
||||
|
||||
### 工作站 + 子设备
|
||||
|
||||
工作站节点的 `children` 列出所有子节点 ID,子节点的 `parent` 指向工作站:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck", "pump_1", "valve_1", "reactor_1"],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"config": {
|
||||
"protocol_type": ["PumpTransferProtocol", "CleanProtocol"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 工作站 + Deck 引用
|
||||
|
||||
工作站节点中通过 `deck` 字段引用 Deck:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck", "sub_device_1"],
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_module.decks:MyDeck"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键约束:**
|
||||
- `_resource_child_name` 必须与 Deck 节点的 `id` 一致
|
||||
- `_resource_type` 为 Deck 类/工厂函数的完整 Python 路径
|
||||
|
||||
---
|
||||
|
||||
## 常见图模式
|
||||
|
||||
### 模式 A:单设备调试
|
||||
|
||||
最简形式,一个设备节点,无连接:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_device",
|
||||
"name": "my_device",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "motor.zdt_x42",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {"port": "/dev/ttyUSB0", "baudrate": 115200},
|
||||
"data": {"status": "idle"}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 B:Protocol 工作站(泵+阀+容器)
|
||||
|
||||
工作站配合泵、阀、容器和物理连接,用于协议编译:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "station", "name": "协议工作站",
|
||||
"class": "workstation", "type": "device", "parent": null,
|
||||
"children": ["pump", "valve", "flask_solvent", "reactor", "waste"],
|
||||
"config": {"protocol_type": ["PumpTransferProtocol"]}
|
||||
},
|
||||
{"id": "pump", "name": "转移泵", "class": "virtual_transfer_pump",
|
||||
"type": "device", "parent": "station",
|
||||
"config": {"port": "VIRTUAL", "max_volume": 25.0},
|
||||
"data": {"status": "Idle", "position": 0.0, "valve_position": "0"}},
|
||||
{"id": "valve", "name": "多通阀", "class": "virtual_multiway_valve",
|
||||
"type": "device", "parent": "station",
|
||||
"config": {"port": "VIRTUAL", "positions": 8}},
|
||||
{"id": "flask_solvent", "name": "溶剂瓶", "type": "container",
|
||||
"class": null, "parent": "station",
|
||||
"config": {"max_volume": 1000.0},
|
||||
"data": {"liquid": [{"liquid_type": "DMF", "liquid_volume": 500}]}},
|
||||
{"id": "reactor", "name": "反应器", "type": "container",
|
||||
"class": null, "parent": "station"},
|
||||
{"id": "waste", "name": "废液瓶", "type": "container",
|
||||
"class": null, "parent": "station"}
|
||||
],
|
||||
"links": [
|
||||
{"source": "pump", "target": "valve", "type": "fluid",
|
||||
"port": {"pump": "transferpump", "valve": "transferpump"}},
|
||||
{"source": "valve", "target": "flask_solvent", "type": "fluid",
|
||||
"port": {"valve": "1", "flask_solvent": "outlet"}},
|
||||
{"source": "valve", "target": "reactor", "type": "fluid",
|
||||
"port": {"valve": "2", "reactor": "inlet"}},
|
||||
{"source": "valve", "target": "waste", "type": "fluid",
|
||||
"port": {"valve": "3", "waste": "inlet"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 C:外部系统工作站 + Deck
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_station", "class": "reaction_station.bioyond",
|
||||
"parent": null, "children": ["bioyond_deck"],
|
||||
"config": {
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_KEY",
|
||||
"material_type_mappings": {},
|
||||
"warehouse_mapping": {}
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "bioyond_deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bioyond_deck", "class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"parent": "bioyond_station", "type": "deck",
|
||||
"config": {"type": "BIOYOND_PolymerReactionStation_Deck", "setup": true}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 D:通信代理(串口设备)
|
||||
|
||||
泵通过串口设备通信,使用 `communication` 类型的 link。加载时系统会自动将串口端口信息写入泵的 `config`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "station", "name": "工作站", "type": "device",
|
||||
"class": "workstation", "parent": null,
|
||||
"children": ["serial_1", "pump_1"]},
|
||||
{"id": "serial_1", "name": "串口", "type": "device",
|
||||
"class": "serial", "parent": "station",
|
||||
"config": {"port": "COM7", "baudrate": 9600}},
|
||||
{"id": "pump_1", "name": "注射泵", "type": "device",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08", "parent": "station"}
|
||||
],
|
||||
"links": [
|
||||
{"source": "pump_1", "target": "serial_1", "type": "communication",
|
||||
"port": {"pump_1": "port", "serial_1": "port"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 启动测试
|
||||
unilab -g unilabos/test/experiments/<name>.json --complete_registry
|
||||
|
||||
# 仅检查注册表
|
||||
python -m unilabos --check_mode --skip_env_check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
处理复杂图文件时,详见 [reference.md](reference.md):ResourceDict 完整字段 schema、Pose 标准化规则、Handle 验证机制、GraphML 格式支持、外部系统工作站完整 config 结构。
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误 | 原因 | 修复 |
|
||||
|------|------|------|
|
||||
| `class` 找不到 | 注册表中无此设备名 | 在 `unilabos/registry/devices/` 或 `resources/` 中搜索正确名称 |
|
||||
| children/parent 不一致 | 子节点 `parent` 与父节点 `children` 不匹配 | 确保双向一致 |
|
||||
| `_resource_child_name` 不匹配 | Deck 引用名与 Deck 节点 `id` 不同 | 保持一致 |
|
||||
| Link 端口错误 | `port` 中的 key 不是 source/target 的 `id` | key 必须是对应节点的 `id` |
|
||||
| 重复 UUID | 多个节点有相同 `uuid` | 删除或修改 UUID |
|
||||
|
||||
---
|
||||
|
||||
## 参考路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| 图文件目录 | `unilabos/test/experiments/` |
|
||||
| 协议测试站 | `unilabos/test/experiments/Protocol_Test_Station/` |
|
||||
| 图加载代码 | `unilabos/resources/graphio.py` |
|
||||
| 节点模型 | `unilabos/resources/resource_tracker.py` |
|
||||
| 设备注册表 | `unilabos/registry/devices/` |
|
||||
| 资源注册表 | `unilabos/registry/resources/` |
|
||||
| 用户文档 | `docs/user_guide/graph_files.md` |
|
||||
255
.cursor/skills/edit-experiment-graph/reference.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 实验图高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含 ResourceDict 完整 schema、Handle 验证、GraphML 格式、Pose 标准化规则和复杂图文件结构。Agent 在需要处理这些场景时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. ResourceDict 完整字段
|
||||
|
||||
`unilabos/resources/resource_tracker.py` 中定义的节点数据模型:
|
||||
|
||||
| 字段 | 类型 | 别名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | `str` | — | 节点唯一标识 |
|
||||
| `uuid` | `str` | — | 全局唯一标识 |
|
||||
| `name` | `str` | — | 显示名称 |
|
||||
| `description` | `str` | — | 描述(默认 `""` ) |
|
||||
| `resource_schema` | `Dict[str, Any]` | `schema` | 资源 schema |
|
||||
| `model` | `Dict[str, Any]` | — | 3D 模型信息 |
|
||||
| `icon` | `str` | — | 图标(默认 `""` ) |
|
||||
| `parent_uuid` | `Optional[str]` | — | 父节点 UUID |
|
||||
| `parent` | `Optional[ResourceDict]` | — | 父节点引用(序列化时 exclude) |
|
||||
| `type` | `Union[Literal["device"], str]` | — | 节点类型 |
|
||||
| `klass` | `str` | `class` | 注册表类名 |
|
||||
| `pose` | `ResourceDictPosition` | — | 位姿信息 |
|
||||
| `config` | `Dict[str, Any]` | — | 配置参数 |
|
||||
| `data` | `Dict[str, Any]` | — | 运行时数据 |
|
||||
| `extra` | `Dict[str, Any]` | — | 扩展数据 |
|
||||
|
||||
### Pose 完整结构(ResourceDictPosition)
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `size` | `{width, height, depth}` | `{0,0,0}` | 节点尺寸 |
|
||||
| `scale` | `{x, y, z}` | `{1,1,1}` | 缩放比例 |
|
||||
| `layout` | `"2d"/"x-y"/"z-y"/"x-z"` | `"x-y"` | 布局方向 |
|
||||
| `position` | `{x, y, z}` | `{0,0,0}` | 2D 位置 |
|
||||
| `position3d` | `{x, y, z}` | `{0,0,0}` | 3D 位置 |
|
||||
| `rotation` | `{x, y, z}` | `{0,0,0}` | 旋转角度 |
|
||||
| `cross_section_type` | `"rectangle"/"circle"/"rounded_rectangle"` | `"rectangle"` | 横截面形状 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Position / Pose 标准化规则
|
||||
|
||||
图文件中的 `position` 有多种写法,加载时自动标准化。
|
||||
|
||||
### 输入格式兼容
|
||||
|
||||
```json
|
||||
// 格式 A: 直接 {x, y, z}(最常用)
|
||||
"position": {"x": 100, "y": 200, "z": 0}
|
||||
|
||||
// 格式 B: 嵌套 position
|
||||
"position": {"position": {"x": 100, "y": 200, "z": 0}}
|
||||
|
||||
// 格式 C: 使用 pose 字段
|
||||
"pose": {"position": {"x": 100, "y": 200, "z": 0}}
|
||||
|
||||
// 格式 D: 顶层 x, y, z(无 position 字段)
|
||||
"x": 100, "y": 200, "z": 0
|
||||
```
|
||||
|
||||
### 标准化流程
|
||||
|
||||
1. **graphio.py `canonicalize_nodes_data`**:若 `position` 不是 dict,从节点顶层提取 `x/y/z` 填入 `pose.position`
|
||||
2. **resource_tracker.py `get_resource_instance_from_dict`**:若 `position.x` 存在(旧格式),转为 `{"position": {"x":..., "y":..., "z":...}}`
|
||||
3. `pose.size` 从 `config.size_x/size_y/size_z` 自动填充
|
||||
|
||||
---
|
||||
|
||||
## 3. Handle 验证
|
||||
|
||||
启动时系统验证 link 中的 `sourceHandle` / `targetHandle` 是否在注册表的 `handles` 中定义。
|
||||
|
||||
```python
|
||||
# unilabos/app/main.py (约 449-481 行)
|
||||
source_handler_keys = [
|
||||
h["handler_key"] for h in materials[source_node.klass]["handles"]
|
||||
if h["io_type"] == "source"
|
||||
]
|
||||
target_handler_keys = [
|
||||
h["handler_key"] for h in materials[target_node.klass]["handles"]
|
||||
if h["io_type"] == "target"
|
||||
]
|
||||
if source_handle not in source_handler_keys:
|
||||
print_status(f"节点 {source_node.id} 的source端点 {source_handle} 不存在", "error")
|
||||
resource_edge_info.pop(...) # 移除非法 link
|
||||
```
|
||||
|
||||
**Handle 定义在注册表 YAML 中:**
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
handles:
|
||||
- handler_key: access
|
||||
io_type: target
|
||||
data_type: fluid
|
||||
side: NORTH
|
||||
label: access
|
||||
```
|
||||
|
||||
> 大多数简单设备不定义 handles,此验证仅对有 `sourceHandle`/`targetHandle` 的 link 生效。
|
||||
|
||||
---
|
||||
|
||||
## 4. GraphML 格式支持
|
||||
|
||||
除 JSON 外,系统也支持 GraphML 格式(`unilabos/resources/graphio.py::read_graphml`)。
|
||||
|
||||
### 与 JSON 的关键差异
|
||||
|
||||
| 特性 | JSON | GraphML |
|
||||
|------|------|---------|
|
||||
| 父子关系 | `parent`/`children` 字段 | `::` 分隔的节点 ID(如 `station::pump_1`) |
|
||||
| 加载后 | 直接解析 | 先 `nx.read_graphml` 再转 JSON 格式 |
|
||||
| 输出 | 不生成副本 | 自动生成等价的 `.json` 文件 |
|
||||
|
||||
### GraphML 转换流程
|
||||
|
||||
```
|
||||
nx.read_graphml(file)
|
||||
↓ 用 label 重映射节点名
|
||||
↓ 从 "::" 推断 parent_relation
|
||||
nx.relabel_nodes + nx.node_link_data
|
||||
↓ canonicalize_nodes_data + canonicalize_links_ports
|
||||
↓ 写出等价 JSON 文件
|
||||
physical_setup_graph + handle_communications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 复杂图文件结构示例
|
||||
|
||||
### 外部系统工作站完整 config
|
||||
|
||||
以 `reaction_station_bioyond.json` 为例,工作站 `config` 中的关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://172.21.103.36:45388",
|
||||
|
||||
"workflow_mappings": {
|
||||
"scheduler_start": {"workflow": "start", "params": {}},
|
||||
"create_order": {"workflow": "create_order", "params": {}}
|
||||
},
|
||||
|
||||
"material_type_mappings": {
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "type-uuid-here"],
|
||||
"BIOYOND_PolymerStation_1BottleCarrier": ["试剂瓶", "type-uuid-here"]
|
||||
},
|
||||
|
||||
"warehouse_mapping": {
|
||||
"堆栈1左": {
|
||||
"uuid": "warehouse-uuid-here",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-1",
|
||||
"A02": "site-uuid-2"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"http_service_config": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 45399,
|
||||
"routes": ["/callback/workflow", "/callback/material"]
|
||||
},
|
||||
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
},
|
||||
|
||||
"size_x": 2700.0,
|
||||
"size_y": 1080.0,
|
||||
"size_z": 2500.0,
|
||||
"protocol_type": [],
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 子设备 Reactor 节点
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "reactor_1",
|
||||
"name": "reactor_1",
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "device",
|
||||
"class": "bioyond_reactor",
|
||||
"position": {"x": 1150, "y": 300, "z": 0},
|
||||
"config": {
|
||||
"reactor_index": 0,
|
||||
"bioyond_workflow_key": "reactor_1"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Deck 节点
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "Bioyond_Deck",
|
||||
"name": "Bioyond_Deck",
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "deck",
|
||||
"class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Link 端口标准化
|
||||
|
||||
`graphio.py::canonicalize_links_ports` 处理 `port` 字段的多种格式:
|
||||
|
||||
```python
|
||||
# 输入: 字符串格式 "(A,B)"
|
||||
"port": "(pump_1, valve_1)"
|
||||
# 输出: 字典格式
|
||||
"port": {"source_id": "pump_1", "target_id": "valve_1"}
|
||||
|
||||
# 输入: 已是字典
|
||||
"port": {"pump_1": "port", "serial_1": "port"}
|
||||
# 保持不变
|
||||
|
||||
# 输入: 无 port 字段
|
||||
# 自动补充空 port
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| ResourceDict 模型 | `unilabos/resources/resource_tracker.py` |
|
||||
| 图加载 + 标准化 | `unilabos/resources/graphio.py` |
|
||||
| Handle 验证 | `unilabos/app/main.py` (449-481 行) |
|
||||
| 反应站图文件 | `unilabos/test/experiments/reaction_station_bioyond.json` |
|
||||
| 配液站图文件 | `unilabos/test/experiments/dispensing_station_bioyond.json` |
|
||||
| 用户文档 | `docs/user_guide/graph_files.md` |
|
||||
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
@@ -0,0 +1,11 @@
|
||||
## 设备接入
|
||||
|
||||
当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`。
|
||||
该指南包含完整的模板和已有设备接口参考。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 动作方法的参数名是接口契约,不可重命名
|
||||
- `status` 字符串必须与同类已有设备一致
|
||||
- `self.data` 必须在 `__init__` 中预填充所有属性字段
|
||||
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()`
|
||||
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "msgcenterpy-team"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
67
.github/workflows/ci-check.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: CI Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
jobs:
|
||||
registry-check:
|
||||
runs-on: windows-latest
|
||||
|
||||
env:
|
||||
# Fix Unicode encoding issue on Windows runner (cp1252 -> utf-8)
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: 1
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: cmd
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniforge
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
channels: robostack-staging,conda-forge,uni-lab
|
||||
channel-priority: flexible
|
||||
activate-environment: check-env
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
- name: Install ROS dependencies, uv and unilabos-msgs
|
||||
run: |
|
||||
echo Installing ROS dependencies...
|
||||
mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y
|
||||
|
||||
- name: Install pip dependencies and unilabos
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Installing pip dependencies...
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
uv pip install pywinauto git+https://github.com/Xuwznln/pylabrobot.git
|
||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
||||
uv pip install .
|
||||
|
||||
- name: Run check mode (complete_registry)
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Running check mode...
|
||||
python -m unilabos --check_mode --skip_env_check
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
shell: bash
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "::error::检测到文件变化!请先在本地运行 'python -m unilabos --complete_registry' 并提交变更"
|
||||
echo "变化的文件:"
|
||||
git diff --name-only
|
||||
exit 1
|
||||
fi
|
||||
echo "检查通过:无文件变化"
|
||||
45
.github/workflows/conda-pack-build.yml
vendored
@@ -13,6 +13,11 @@ on:
|
||||
required: false
|
||||
default: 'win-64'
|
||||
type: string
|
||||
build_full:
|
||||
description: '是否构建完整版 unilabos-full (默认构建轻量版 unilabos)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
build-conda-pack:
|
||||
@@ -24,7 +29,7 @@ jobs:
|
||||
platform: linux-64
|
||||
env_file: unilabos-linux-64.yaml
|
||||
script_ext: sh
|
||||
- os: macos-13 # Intel
|
||||
- os: macos-15 # Intel (via Rosetta)
|
||||
platform: osx-64
|
||||
env_file: unilabos-osx-64.yaml
|
||||
script_ext: sh
|
||||
@@ -57,7 +62,7 @@ jobs:
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
@@ -69,7 +74,7 @@ jobs:
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
python-version: '3.11.11'
|
||||
python-version: '3.11.14'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: flexible
|
||||
activate-environment: unilab
|
||||
@@ -81,7 +86,14 @@ jobs:
|
||||
run: |
|
||||
echo Installing unilabos and dependencies to unilab environment...
|
||||
echo Using mamba for faster and more reliable dependency resolution...
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
echo Build full: ${{ github.event.inputs.build_full }}
|
||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
||||
echo Installing unilabos-full ^(complete package^)...
|
||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
) else (
|
||||
echo Installing unilabos ^(minimal package^)...
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
)
|
||||
|
||||
- name: Install conda-pack, unilabos and dependencies (Unix)
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
||||
@@ -89,7 +101,14 @@ jobs:
|
||||
run: |
|
||||
echo "Installing unilabos and dependencies to unilab environment..."
|
||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
echo "Build full: ${{ github.event.inputs.build_full }}"
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo "Installing unilabos-full (complete package)..."
|
||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
else
|
||||
echo "Installing unilabos (minimal package)..."
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
fi
|
||||
|
||||
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||
@@ -293,7 +312,7 @@ jobs:
|
||||
|
||||
- name: Upload distribution package
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||
path: dist-package/
|
||||
@@ -308,7 +327,12 @@ jobs:
|
||||
echo ==========================================
|
||||
echo Platform: ${{ matrix.platform }}
|
||||
echo Branch: ${{ github.event.inputs.branch }}
|
||||
echo Python version: 3.11.11
|
||||
echo Python version: 3.11.14
|
||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
||||
echo Package: unilabos-full ^(complete^)
|
||||
) else (
|
||||
echo Package: unilabos ^(minimal^)
|
||||
)
|
||||
echo.
|
||||
echo Distribution package contents:
|
||||
dir dist-package
|
||||
@@ -328,7 +352,12 @@ jobs:
|
||||
echo "=========================================="
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "Branch: ${{ github.event.inputs.branch }}"
|
||||
echo "Python version: 3.11.11"
|
||||
echo "Python version: 3.11.14"
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo "Package: unilabos-full (complete)"
|
||||
else
|
||||
echo "Package: unilabos (minimal)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Distribution package contents:"
|
||||
ls -lh dist-package/
|
||||
|
||||
37
.github/workflows/deploy-docs.yml
vendored
@@ -1,10 +1,12 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# 在 CI Check 成功后自动触发(仅 main 分支)
|
||||
workflow_run:
|
||||
workflows: ["CI Check"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
@@ -33,12 +35,19 @@ concurrency:
|
||||
jobs:
|
||||
# Build documentation
|
||||
build:
|
||||
# 只在以下情况运行:
|
||||
# 1. workflow_run 触发且 CI Check 成功
|
||||
# 2. 手动触发
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
# workflow_run 时使用触发工作流的分支,手动触发时使用输入的分支
|
||||
ref: ${{ github.event.workflow_run.head_branch || github.event.inputs.branch || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniforge (with mamba)
|
||||
@@ -46,7 +55,7 @@ jobs:
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
python-version: '3.11.11'
|
||||
python-version: '3.11.14'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: flexible
|
||||
activate-environment: unilab
|
||||
@@ -75,8 +84,10 @@ jobs:
|
||||
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@v4
|
||||
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
uses: actions/configure-pages@v5
|
||||
if: |
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
|
||||
- name: Build Sphinx documentation
|
||||
run: |
|
||||
@@ -94,14 +105,18 @@ jobs:
|
||||
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
if: |
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
with:
|
||||
path: docs/_build/html
|
||||
|
||||
# Deploy to GitHub Pages
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
if: |
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
48
.github/workflows/multi-platform-build.yml
vendored
@@ -1,11 +1,16 @@
|
||||
name: Multi-Platform Conda Build
|
||||
|
||||
on:
|
||||
# 在 CI Check 工作流完成后触发(仅限 main/dev 分支)
|
||||
workflow_run:
|
||||
workflows: ["CI Check"]
|
||||
types:
|
||||
- completed
|
||||
branches: [main, dev]
|
||||
# 支持 tag 推送(不依赖 CI Check)
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
@@ -17,9 +22,37 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_ci_check:
|
||||
description: '跳过等待 CI Check (手动触发时可选)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run'
|
||||
outputs:
|
||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
||||
steps:
|
||||
- name: Check CI status
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||
echo "CI Check passed, proceeding with build"
|
||||
else
|
||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: [wait-for-ci]
|
||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
||||
if: |
|
||||
always() &&
|
||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -27,7 +60,7 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
platform: linux-64
|
||||
env_file: unilabos-linux-64.yaml
|
||||
- os: macos-13 # Intel
|
||||
- os: macos-15 # Intel (via Rosetta)
|
||||
platform: osx-64
|
||||
env_file: unilabos-osx-64.yaml
|
||||
- os: macos-latest # ARM64
|
||||
@@ -44,8 +77,10 @@ jobs:
|
||||
shell: bash -l {0}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if platform should be built
|
||||
@@ -69,7 +104,6 @@ jobs:
|
||||
channels: conda-forge,robostack-staging,defaults
|
||||
channel-priority: strict
|
||||
activate-environment: build-env
|
||||
auto-activate-base: false
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
@@ -115,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Upload conda package artifacts
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: conda-package-${{ matrix.platform }}
|
||||
path: conda-packages-temp
|
||||
|
||||
115
.github/workflows/unilabos-conda-build.yml
vendored
@@ -1,32 +1,69 @@
|
||||
name: UniLabOS Conda Build
|
||||
|
||||
on:
|
||||
# 在 CI Check 成功后自动触发
|
||||
workflow_run:
|
||||
workflows: ["CI Check"]
|
||||
types: [completed]
|
||||
branches: [main, dev]
|
||||
# 标签推送时直接触发(发布版本)
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64'
|
||||
required: false
|
||||
default: 'linux-64'
|
||||
build_full:
|
||||
description: '是否构建 unilabos-full 完整包 (默认只构建 unilabos 基础包)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
upload_to_anaconda:
|
||||
description: '是否上传到Anaconda.org'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_ci_check:
|
||||
description: '跳过等待 CI Check (手动触发时可选)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run'
|
||||
outputs:
|
||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
||||
steps:
|
||||
- name: Check CI status
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||
echo "CI Check passed, proceeding with build"
|
||||
else
|
||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: [wait-for-ci]
|
||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
||||
if: |
|
||||
always() &&
|
||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux-64
|
||||
- os: macos-13 # Intel
|
||||
- os: macos-15 # Intel (via Rosetta)
|
||||
platform: osx-64
|
||||
- os: macos-latest # ARM64
|
||||
platform: osx-arm64
|
||||
@@ -40,8 +77,10 @@ jobs:
|
||||
shell: bash -l {0}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if platform should be built
|
||||
@@ -65,7 +104,6 @@ jobs:
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: strict
|
||||
activate-environment: build-env
|
||||
auto-activate-base: false
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
@@ -81,12 +119,61 @@ jobs:
|
||||
conda list | grep -E "(rattler-build|anaconda-client)"
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "OS: ${{ matrix.os }}"
|
||||
echo "Building UniLabOS package"
|
||||
echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}"
|
||||
echo "Building packages:"
|
||||
echo " - unilabos-env (environment dependencies)"
|
||||
echo " - unilabos (with pip package)"
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo " - unilabos-full (complete package)"
|
||||
fi
|
||||
|
||||
- name: Build conda package
|
||||
- name: Build unilabos-env (conda environment only, noarch)
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
rattler-build build -r .conda/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||
echo "Building unilabos-env (conda environment dependencies)..."
|
||||
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||
|
||||
- name: Upload unilabos-env to Anaconda.org (if enabled)
|
||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
echo "Uploading unilabos-env to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-env*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
- name: Build unilabos (with pip package)
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
echo "Building unilabos package..."
|
||||
# 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取
|
||||
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
||||
|
||||
- name: Upload unilabos to Anaconda.org (if enabled)
|
||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
echo "Uploading unilabos to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
- name: Build unilabos-full - Only when explicitly requested
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
github.event.inputs.build_full == 'true'
|
||||
run: |
|
||||
echo "Building unilabos-full package on ${{ matrix.platform }}..."
|
||||
rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
||||
|
||||
- name: Upload unilabos-full to Anaconda.org (if enabled)
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
github.event.inputs.build_full == 'true' &&
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
echo "Uploading unilabos-full to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-full*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
- name: List built packages
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
@@ -108,17 +195,9 @@ jobs:
|
||||
|
||||
- name: Upload conda package artifacts
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: conda-package-unilabos-${{ matrix.platform }}
|
||||
path: conda-packages-temp
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload to Anaconda.org (uni-lab organization)
|
||||
if: github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
for package in $(find ./output -name "*.conda"); do
|
||||
echo "Uploading $package to uni-lab organization..."
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
2
.gitignore
vendored
@@ -1,8 +1,10 @@
|
||||
cursor_docs/
|
||||
configs/
|
||||
temp/
|
||||
output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
.cursorignore
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
21
AGENTS.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Uni-Lab-OS AI Agent 指南
|
||||
|
||||
## 设备接入
|
||||
|
||||
当用户要求添加/接入新设备时,读取 `docs/ai_guides/add_device.md` 并按其流程执行。
|
||||
该指南完全自包含,包含物模型模板、现有设备接口快照、常见错误和验证清单。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 动作方法的参数名是接口契约,不可重命名(如 `volume` 不能改为 `volume_ml`)
|
||||
- `status` 字符串必须与同类已有设备一致(如 `"Idle"` 不能改为 `"就绪"`)
|
||||
- `self.data` 必须在 `__init__` 中预填充所有属性字段
|
||||
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
|
||||
## 项目结构
|
||||
|
||||
- 设备驱动:`unilabos/devices/<category>/<device_name>.py`
|
||||
- 设备注册表:`unilabos/registry/devices/<device_name>.yaml`
|
||||
- 实验图文件:`unilabos/test/experiments/*.json`
|
||||
- 人类开发文档:`docs/developer_guide/`
|
||||
- AI 专用指南:`docs/ai_guides/`
|
||||
14
CLAUDE.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Uni-Lab-OS
|
||||
|
||||
## 设备接入
|
||||
|
||||
读取 `docs/ai_guides/add_device.md` 获取完整的自包含指南。
|
||||
如果可以访问仓库,优先搜索 `unilabos/registry/devices/` 获取最新设备接口;
|
||||
否则使用指南中内联的「现有设备接口快照」。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 动作方法的参数名是接口契约,不可重命名(如 `volume` 不能改为 `volume_ml`)
|
||||
- `status` 字符串必须与同类已有设备一致(如 `"Idle"` 不能改为 `"就绪"`)
|
||||
- `self.data` 必须在 `__init__` 中预填充所有属性字段
|
||||
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
@@ -1,4 +1,5 @@
|
||||
recursive-include unilabos/test *
|
||||
recursive-include unilabos/utils *
|
||||
recursive-include unilabos/registry *.yaml
|
||||
recursive-include unilabos/app/web/static *
|
||||
recursive-include unilabos/app/web/templates *
|
||||
|
||||
17
NOTICE
Normal file
@@ -0,0 +1,17 @@
|
||||
# Uni-Lab-OS Licensing Notice
|
||||
|
||||
This project uses a dual licensing structure:
|
||||
|
||||
## 1. Main Framework - GPL-3.0
|
||||
|
||||
- unilabos/ (except unilabos/devices/)
|
||||
- docs/
|
||||
- tests/
|
||||
|
||||
See [LICENSE](LICENSE) for details.
|
||||
|
||||
## 2. Device Drivers - DP Technology Proprietary License
|
||||
|
||||
- unilabos/devices/
|
||||
|
||||
See [unilabos/devices/LICENSE](unilabos/devices/LICENSE) for details.
|
||||
90
README.md
@@ -8,17 +8,13 @@
|
||||
|
||||
**English** | [中文](README_zh.md)
|
||||
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
||||
|
||||
Uni-Lab-OS is a platform for laboratory automation, designed to connect and control various experimental equipment, enabling automation and standardization of experimental workflows.
|
||||
|
||||
## 🏆 Competition
|
||||
|
||||
Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.dp.tech/competitions/1451645258) to explore automated synthesis with Uni-Lab-OS!
|
||||
|
||||
## Key Features
|
||||
|
||||
- Multi-device integration management
|
||||
@@ -31,41 +27,89 @@ Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.d
|
||||
|
||||
Detailed documentation can be found at:
|
||||
|
||||
- [Online Documentation](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||
- [Online Documentation](https://deepmodeling.github.io/Uni-Lab-OS/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system:
|
||||
### 1. Setup Conda Environment
|
||||
|
||||
Uni-Lab-OS recommends using `mamba` for environment management. Choose the package that fits your needs:
|
||||
|
||||
| Package | Use Case | Contents |
|
||||
|---------|----------|----------|
|
||||
| `unilabos` | **Recommended for most users** | Complete package, ready to use |
|
||||
| `unilabos-env` | Developers (editable install) | Environment only, install unilabos via pip |
|
||||
| `unilabos-full` | Simulation/Visualization | unilabos + ROS2 Desktop + Gazebo + MoveIt |
|
||||
|
||||
```bash
|
||||
# Create new environment
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba create -n unilab python=3.11.14
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# Option A: Standard installation (recommended for most users)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# Option B: For developers (editable mode development)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# Then install unilabos and dependencies:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Option C: Full installation (simulation/visualization)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
## Install Dev Uni-Lab-OS
|
||||
**When to use which?**
|
||||
- **unilabos**: Standard installation for production deployment and general usage (recommended)
|
||||
- **unilabos-env**: For developers who need `pip install -e .` editable mode, modify source code
|
||||
- **unilabos-full**: For simulation (Gazebo), visualization (rviz2), and Jupyter notebooks
|
||||
|
||||
### 2. Clone Repository (Optional, for developers)
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
# Clone the repository (only needed for development or examples)
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# Install Uni-Lab-OS
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. Start Uni-Lab System:
|
||||
3. Start Uni-Lab System
|
||||
|
||||
Please refer to [Documentation - Boot Examples](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html)
|
||||
Please refer to [Documentation - Boot Examples](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
|
||||
|
||||
4. Best Practice
|
||||
|
||||
See [Best Practice Guide](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html)
|
||||
|
||||
## Message Format
|
||||
|
||||
Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) page.
|
||||
Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/deepmodeling/Uni-Lab-OS/releases) page.
|
||||
|
||||
## Citation
|
||||
|
||||
If you use [Uni-Lab-OS](https://arxiv.org/abs/2512.21766) in academic research, please cite:
|
||||
|
||||
```bibtex
|
||||
@article{gao2025unilabos,
|
||||
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
|
||||
doi = {10.48550/arXiv.2512.21766},
|
||||
publisher = {arXiv},
|
||||
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and
|
||||
Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and
|
||||
Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and
|
||||
Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
|
||||
year = {2025}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
This project uses a dual licensing structure:
|
||||
|
||||
- **Main Framework**: GPL-3.0 - see [LICENSE](LICENSE)
|
||||
- **Device Drivers** (`unilabos/devices/`): DP Technology Proprietary License
|
||||
|
||||
See [NOTICE](NOTICE) for complete licensing details.
|
||||
|
||||
## Project Statistics
|
||||
|
||||
@@ -77,4 +121,4 @@ This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for det
|
||||
|
||||
## Contact Us
|
||||
|
||||
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
|
||||
90
README_zh.md
@@ -8,17 +8,13 @@
|
||||
|
||||
[English](README.md) | **中文**
|
||||
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
||||
|
||||
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
||||
|
||||
## 🏆 比赛
|
||||
|
||||
欢迎参加[有机化学合成智能实验大赛](https://bohrium.dp.tech/competitions/1451645258),使用 Uni-Lab-OS 探索自动化合成!
|
||||
|
||||
## 核心特点
|
||||
|
||||
- 多设备集成管理
|
||||
@@ -31,43 +27,89 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控
|
||||
|
||||
详细文档可在以下位置找到:
|
||||
|
||||
- [在线文档](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||
- [在线文档](https://deepmodeling.github.io/Uni-Lab-OS/)
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 配置 Conda 环境
|
||||
### 1. 配置 Conda 环境
|
||||
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的需求选择合适的安装包:
|
||||
|
||||
| 安装包 | 适用场景 | 包含内容 |
|
||||
|--------|----------|----------|
|
||||
| `unilabos` | **推荐大多数用户** | 完整安装包,开箱即用 |
|
||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||
| `unilabos-full` | 仿真/可视化 | unilabos + ROS2 桌面版 + Gazebo + MoveIt |
|
||||
|
||||
```bash
|
||||
# 创建新环境
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba create -n unilab python=3.11.14
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后安装 unilabos 和依赖:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 方案 C:完整安装(仿真/可视化)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
2. 安装开发版 Uni-Lab-OS:
|
||||
**如何选择?**
|
||||
- **unilabos**:标准安装,适用于生产部署和日常使用(推荐)
|
||||
- **unilabos-env**:开发者使用,支持 `pip install -e .` 可编辑模式,可修改源代码
|
||||
- **unilabos-full**:需要仿真(Gazebo)、可视化(rviz2)或 Jupyter Notebook
|
||||
|
||||
### 2. 克隆仓库(可选,供开发者使用)
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
# 克隆仓库(仅开发或查看示例时需要)
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 安装 Uni-Lab-OS
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. 启动 Uni-Lab 系统:
|
||||
3. 启动 Uni-Lab 系统
|
||||
|
||||
请见[文档-启动样例](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html)
|
||||
请见[文档-启动样例](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
|
||||
|
||||
4. 最佳实践
|
||||
|
||||
请见[最佳实践指南](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html)
|
||||
|
||||
## 消息格式
|
||||
|
||||
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。
|
||||
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/deepmodeling/Uni-Lab-OS/releases) 页面找到已构建的版本。
|
||||
|
||||
## 引用
|
||||
|
||||
如果您在学术研究中使用 [Uni-Lab-OS](https://arxiv.org/abs/2512.21766),请引用:
|
||||
|
||||
```bibtex
|
||||
@article{gao2025unilabos,
|
||||
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
|
||||
doi = {10.48550/arXiv.2512.21766},
|
||||
publisher = {arXiv},
|
||||
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and
|
||||
Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and
|
||||
Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and
|
||||
Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
|
||||
year = {2025}
|
||||
}
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。
|
||||
本项目采用双许可证结构:
|
||||
|
||||
- **主框架**:GPL-3.0 - 详见 [LICENSE](LICENSE)
|
||||
- **设备驱动** (`unilabos/devices/`):深势科技专有许可证
|
||||
|
||||
完整许可证说明请参阅 [NOTICE](NOTICE)。
|
||||
|
||||
## 项目统计
|
||||
|
||||
@@ -79,4 +121,4 @@ Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在
|
||||
|
||||
## 联系我们
|
||||
|
||||
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
|
||||
1100
docs/ai_guides/add_device.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 行为,指南提供领域知识。两者独立维护
|
||||
@@ -24,7 +24,7 @@ extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
|
||||
"sphinx_rtd_theme",
|
||||
"sphinxcontrib.mermaid"
|
||||
"sphinxcontrib.mermaid",
|
||||
]
|
||||
|
||||
source_suffix = {
|
||||
@@ -58,7 +58,7 @@ html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# sphinx-book-theme 主题选项
|
||||
html_theme_options = {
|
||||
"repository_url": "https://github.com/用户名/Uni-Lab",
|
||||
"repository_url": "https://github.com/deepmodeling/Uni-Lab-OS",
|
||||
"use_repository_button": True,
|
||||
"use_issues_button": True,
|
||||
"use_edit_page_button": True,
|
||||
|
||||
@@ -12,3 +12,7 @@ sphinx-copybutton>=0.5.0
|
||||
|
||||
# 用于自动摘要生成
|
||||
sphinx-autobuild>=2024.2.4
|
||||
|
||||
# 用于PDF导出 (rinohtype方案,纯Python无需LaTeX)
|
||||
rinohtype>=0.5.4
|
||||
sphinx-simplepdf>=1.6.0
|
||||
@@ -31,6 +31,14 @@
|
||||
|
||||
详细的安装步骤请参考 [安装指南](installation.md)。
|
||||
|
||||
**选择合适的安装包:**
|
||||
|
||||
| 安装包 | 适用场景 | 包含组件 |
|
||||
|--------|----------|----------|
|
||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
||||
|
||||
**关键步骤:**
|
||||
|
||||
```bash
|
||||
@@ -38,15 +46,30 @@
|
||||
# 下载 Miniforge: https://github.com/conda-forge/miniforge/releases
|
||||
|
||||
# 2. 创建 Conda 环境
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba create -n unilab python=3.11.14
|
||||
|
||||
# 3. 激活环境
|
||||
mamba activate unilab
|
||||
|
||||
# 4. 安装 Uni-Lab-OS
|
||||
# 4. 安装 Uni-Lab-OS(选择其一)
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
pip install -e /path/to/Uni-Lab-OS # 可编辑安装
|
||||
uv pip install -r unilabos/utils/requirements.txt # 安装 pip 依赖
|
||||
|
||||
# 方案 C:完整版(仿真/可视化)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**选择建议:**
|
||||
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
||||
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
||||
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
||||
|
||||
#### 1.2 验证安装
|
||||
|
||||
```bash
|
||||
@@ -416,6 +439,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
1. 访问 Web 界面,进入"仪器耗材"模块
|
||||
2. 在"仪器设备"区域找到并添加上述设备
|
||||
3. 在"物料耗材"区域找到并添加容器
|
||||
4. 在workstation中配置protocol_type包含PumpTransferProtocol
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -426,8 +452,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
**操作步骤:**
|
||||
|
||||
1. 将两个 `container` 拖拽到 `workstation` 中
|
||||
2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
3. 在画布上连接它们(建立父子关系)
|
||||
2. 将 `virtual_multiway_valve` 拖拽到 `workstation` 中
|
||||
3. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
4. 在画布上连接它们(建立父子关系)
|
||||
|
||||

|
||||
|
||||
@@ -768,7 +795,43 @@ Waiting for host service...
|
||||
|
||||
详细的设备驱动编写指南请参考 [添加设备驱动](../developer_guide/add_device.md)。
|
||||
|
||||
#### 9.1 为什么需要自定义设备?
|
||||
#### 9.1 开发环境准备
|
||||
|
||||
**推荐使用 `unilabos-env` + `pip install -e .` + `uv pip install`** 进行设备开发:
|
||||
|
||||
```bash
|
||||
# 1. 创建环境并安装 unilabos-env(ROS2 + conda 依赖 + uv)
|
||||
mamba create -n unilab python=3.11.14
|
||||
conda activate unilab
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
|
||||
# 2. 克隆代码
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 3. 以可编辑模式安装(推荐使用脚本,自动检测中文环境)
|
||||
python scripts/dev_install.py
|
||||
|
||||
# 或手动安装:
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
```
|
||||
|
||||
**为什么使用这种方式?**
|
||||
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
||||
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
||||
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
||||
- 使用 `uv` 替代 `pip`,安装速度更快
|
||||
- 可编辑模式:代码修改**立即生效**,无需重新安装
|
||||
|
||||
**如果安装失败或速度太慢**,可以手动执行(使用清华镜像):
|
||||
|
||||
```bash
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
#### 9.2 为什么需要自定义设备?
|
||||
|
||||
Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要集成:
|
||||
|
||||
@@ -777,7 +840,7 @@ Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要
|
||||
- 特殊的实验流程
|
||||
- 第三方设备集成
|
||||
|
||||
#### 9.2 创建 Python 包
|
||||
#### 9.3 创建 Python 包
|
||||
|
||||
为了方便开发和管理,建议为您的实验室创建独立的 Python 包。
|
||||
|
||||
@@ -814,7 +877,7 @@ touch my_lab_devices/my_lab_devices/__init__.py
|
||||
touch my_lab_devices/my_lab_devices/devices/__init__.py
|
||||
```
|
||||
|
||||
#### 9.3 创建 setup.py
|
||||
#### 9.4 创建 setup.py
|
||||
|
||||
```python
|
||||
# my_lab_devices/setup.py
|
||||
@@ -845,7 +908,7 @@ setup(
|
||||
)
|
||||
```
|
||||
|
||||
#### 9.4 开发安装
|
||||
#### 9.5 开发安装
|
||||
|
||||
使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效:
|
||||
|
||||
@@ -860,7 +923,7 @@ pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
- 方便调试和测试
|
||||
- 支持版本控制(git)
|
||||
|
||||
#### 9.5 编写设备驱动
|
||||
#### 9.6 编写设备驱动
|
||||
|
||||
创建设备驱动文件:
|
||||
|
||||
@@ -1001,7 +1064,7 @@ class MyPump:
|
||||
- **返回 Dict**:所有动作方法返回字典类型
|
||||
- **文档字符串**:详细说明参数和功能
|
||||
|
||||
#### 9.6 测试设备驱动
|
||||
#### 9.7 测试设备驱动
|
||||
|
||||
创建简单的测试脚本:
|
||||
|
||||
@@ -1807,7 +1870,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
||||
|
||||
#### 14.5 社区支持
|
||||
|
||||
- **GitHub Issues**:[https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/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)
|
||||
|
||||
---
|
||||
|
||||
@@ -463,7 +463,7 @@ Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法
|
||||
### 使用示例
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||
from unilabos.resources.resource_tracker import ResourceDictInstance
|
||||
|
||||
# 旧格式节点
|
||||
old_format_node = {
|
||||
@@ -477,10 +477,10 @@ old_format_node = {
|
||||
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
|
||||
|
||||
# 访问标准化后的数据
|
||||
print(instance.res_content.id) # "pump_1"
|
||||
print(instance.res_content.uuid) # 自动生成的 UUID
|
||||
print(instance.res_content.id) # "pump_1"
|
||||
print(instance.res_content.uuid) # 自动生成的 UUID
|
||||
print(instance.res_content.config) # {}
|
||||
print(instance.res_content.data) # {}
|
||||
print(instance.res_content.data) # {}
|
||||
```
|
||||
|
||||
### 格式迁移建议
|
||||
@@ -857,4 +857,4 @@ class ResourceDictPosition(BaseModel):
|
||||
- 在 Web 界面中使用模板创建
|
||||
- 参考示例文件:`test/experiments/` 目录
|
||||
- 查看 ResourceDict 源码了解完整定义
|
||||
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
- [GitHub 讨论区](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
||||
|
||||
BIN
docs/user_guide/image/add_protocol.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 415 KiB |
@@ -13,15 +13,26 @@
|
||||
- 开发者需要 Git 和基本的 Python 开发知识
|
||||
- 自定义 msgs 需要 GitHub 账号
|
||||
|
||||
## 安装包选择
|
||||
|
||||
Uni-Lab-OS 提供三个安装包版本,根据您的需求选择:
|
||||
|
||||
| 安装包 | 适用场景 | 包含组件 | 磁盘占用 |
|
||||
|--------|----------|----------|----------|
|
||||
| **unilabos** | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | ~2-3 GB |
|
||||
| **unilabos-env** | 开发者环境(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | ~2 GB |
|
||||
| **unilabos-full** | 仿真可视化、完整功能体验 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | ~8-10 GB |
|
||||
|
||||
## 安装方式选择
|
||||
|
||||
根据您的使用场景,选择合适的安装方式:
|
||||
|
||||
| 安装方式 | 适用人群 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
|
||||
| 安装方式 | 适用人群 | 推荐安装包 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ----------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 快速体验、演示 | 预打包环境 | 离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | **大多数用户** | `unilabos` | 完整功能,开箱即用 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | `unilabos-env` | 可编辑模式,支持自定义开发 | 20-30 分钟 |
|
||||
| **仿真/可视化** | 仿真测试、可视化调试 | `unilabos-full` | 含 Gazebo、rviz2、MoveIt | 30-60 分钟 |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +48,7 @@
|
||||
|
||||
#### 第一步:下载预打包环境
|
||||
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
|
||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||
|
||||
@@ -144,17 +155,38 @@ bash Miniforge3-$(uname)-$(uname -m).sh
|
||||
使用以下命令创建 Uni-Lab 专用环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
|
||||
mamba create -n unilab python=3.11.14 # 目前ros2组件依赖版本大多为3.11.14
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 选择安装包(三选一):
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后安装 unilabos 和 pip 依赖:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 方案 C:完整版(含仿真和可视化工具)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- `-n unilab`: 创建名为 "unilab" 的环境
|
||||
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
|
||||
- `uni-lab::unilabos`: 安装 unilabos 完整包,开箱即用(推荐)
|
||||
- `uni-lab::unilabos-env`: 仅安装环境依赖,适合开发者使用 `pip install -e .`
|
||||
- `uni-lab::unilabos-full`: 安装完整包(含 ROS2 Desktop、Gazebo、MoveIt 等)
|
||||
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
||||
|
||||
**包选择建议**:
|
||||
- **日常使用/生产部署**:安装 `unilabos`(推荐,完整功能,开箱即用)
|
||||
- **开发者**:安装 `unilabos-env`,然后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖,再 `pip install -e .` 进行可编辑安装
|
||||
- **仿真/可视化**:安装 `unilabos-full`(Gazebo、rviz2、MoveIt)
|
||||
|
||||
**如果遇到网络问题**,可以使用清华镜像源加速下载:
|
||||
|
||||
```bash
|
||||
@@ -163,8 +195,14 @@ mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/m
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
|
||||
# 然后重新执行安装命令
|
||||
# 然后重新执行安装命令(推荐标准安装)
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging
|
||||
|
||||
# 或完整版(仿真/可视化)
|
||||
mamba create -n unilab uni-lab::unilabos-full -c robostack-staging
|
||||
|
||||
# pip 安装时使用清华镜像(开发者安装时使用)
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
### 第三步:激活环境
|
||||
@@ -189,13 +227,13 @@ conda activate unilab
|
||||
### 第一步:克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
如果您需要贡献代码,建议先 Fork 仓库:
|
||||
|
||||
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
|
||||
1. 访问 https://github.com/deepmodeling/Uni-Lab-OS
|
||||
2. 点击右上角的 "Fork" 按钮
|
||||
3. Clone 您的 Fork 版本:
|
||||
```bash
|
||||
@@ -203,58 +241,87 @@ cd Uni-Lab-OS
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
### 第二步:安装基础环境
|
||||
### 第二步:安装开发环境(unilabos-env)
|
||||
|
||||
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。
|
||||
|
||||
#### 选项 A:通过一键安装(推荐)
|
||||
|
||||
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
|
||||
**重要**:开发者请使用 `unilabos-env` 包,它专为开发者设计:
|
||||
- 包含 ROS2 核心组件和消息包(ros-humble-ros-core、std-msgs、geometry-msgs 等)
|
||||
- 包含 transforms3d、cv-bridge、tf2 等 conda 依赖
|
||||
- 包含 `uv` 工具,用于快速安装 pip 依赖
|
||||
- **不包含** pip 依赖和 unilabos 包(由 `pip install -e .` 和 `uv pip install` 安装)
|
||||
|
||||
```bash
|
||||
# 创建并激活环境
|
||||
mamba create -n unilab python=3.11.14
|
||||
conda activate unilab
|
||||
|
||||
# 安装开发者环境包(ROS2 + conda 依赖 + uv)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
#### 选项 B:通过手动安装
|
||||
### 第三步:安装 pip 依赖和可编辑模式安装
|
||||
|
||||
参考上文"方式二:手动安装",创建并安装环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11
|
||||
conda activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖
|
||||
|
||||
### 第三步:切换到开发版本
|
||||
|
||||
现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本:
|
||||
克隆代码并安装依赖:
|
||||
|
||||
```bash
|
||||
# 确保环境已激活
|
||||
conda activate unilab
|
||||
|
||||
# 卸载 pip 安装的 unilabos(保留所有 conda 依赖)
|
||||
pip uninstall unilabos -y
|
||||
|
||||
# 克隆 dev 分支(如果还未克隆)
|
||||
cd /path/to/your/workspace
|
||||
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
# 或者如果已经克隆,切换到 dev 分支
|
||||
# 克隆仓库(如果还未克隆)
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 切换到 dev 分支(可选)
|
||||
git checkout dev
|
||||
git pull
|
||||
|
||||
# 以可编辑模式安装开发版 unilabos
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
**推荐:使用安装脚本**(自动检测中文环境,使用 uv 加速):
|
||||
|
||||
- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装
|
||||
- `-i`: 使用清华镜像源加速下载
|
||||
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
|
||||
```bash
|
||||
# 自动检测中文环境,如果是中文系统则使用清华镜像
|
||||
python scripts/dev_install.py
|
||||
|
||||
# 或者手动指定:
|
||||
python scripts/dev_install.py --china # 强制使用清华镜像
|
||||
python scripts/dev_install.py --no-mirror # 强制使用 PyPI
|
||||
python scripts/dev_install.py --skip-deps # 跳过 pip 依赖安装
|
||||
python scripts/dev_install.py --use-pip # 使用 pip 而非 uv
|
||||
```
|
||||
|
||||
**手动安装**(如果脚本安装失败或速度太慢):
|
||||
|
||||
```bash
|
||||
# 1. 安装 unilabos(可编辑模式)
|
||||
pip install -e .
|
||||
|
||||
# 2. 使用 uv 安装 pip 依赖(推荐,速度更快)
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 国内用户使用清华镜像:
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- `uv` 已包含在 `unilabos-env` 中,无需单独安装
|
||||
- `unilabos/utils/requirements.txt` 包含运行 unilabos 所需的所有 pip 依赖
|
||||
- 部分特殊包(如 pylabrobot)会在运行时由 unilabos 自动检测并安装
|
||||
|
||||
**为什么使用可编辑模式?**
|
||||
|
||||
- `-e` (editable mode):代码修改**立即生效**,无需重新安装
|
||||
- 适合开发调试:修改代码后直接运行测试
|
||||
- 与 `unilabos-env` 配合:环境依赖由 conda 管理,unilabos 代码由 pip 管理
|
||||
|
||||
**验证安装**:
|
||||
|
||||
```bash
|
||||
# 检查 unilabos 版本
|
||||
python -c "import unilabos; print(unilabos.__version__)"
|
||||
|
||||
# 检查安装位置(应该指向你的代码目录)
|
||||
pip show unilabos | grep Location
|
||||
```
|
||||
|
||||
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
||||
|
||||
@@ -464,7 +531,45 @@ cd $CONDA_PREFIX/envs/unilab
|
||||
|
||||
### 问题 8: 环境很大,有办法减小吗?
|
||||
|
||||
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。
|
||||
**解决方案**:
|
||||
|
||||
1. **使用 `unilabos` 标准版**(推荐大多数用户):
|
||||
```bash
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
标准版包含完整功能,环境大小约 2-3GB(相比完整版的 8-10GB)。
|
||||
|
||||
2. **使用 `unilabos-env` 开发者版**(最小化):
|
||||
```bash
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后手动安装依赖
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
```
|
||||
开发者版只包含环境依赖,体积最小约 2GB。
|
||||
|
||||
3. **按需安装额外组件**:
|
||||
如果后续需要特定功能,可以单独安装:
|
||||
```bash
|
||||
# 需要 Jupyter
|
||||
mamba install jupyter jupyros
|
||||
|
||||
# 需要可视化
|
||||
mamba install matplotlib opencv
|
||||
|
||||
# 需要仿真(注意:这会安装大量依赖)
|
||||
mamba install ros-humble-gazebo-ros
|
||||
```
|
||||
|
||||
4. **预打包环境问题**:
|
||||
预打包环境(方式一)包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。
|
||||
|
||||
**包选择建议**:
|
||||
| 需求 | 推荐包 | 预估大小 |
|
||||
|------|--------|----------|
|
||||
| 日常使用/生产部署 | `unilabos` | ~2-3 GB |
|
||||
| 开发调试(可编辑模式) | `unilabos-env` | ~2 GB |
|
||||
| 仿真/可视化 | `unilabos-full` | ~8-10 GB |
|
||||
|
||||
### 问题 9: 如何更新到最新版本?
|
||||
|
||||
@@ -503,14 +608,15 @@ mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-f
|
||||
## 需要帮助?
|
||||
|
||||
- **故障排查**: 查看更详细的故障排查信息
|
||||
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- **GitHub Issues**: [报告问题](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
- **开发者文档**: 查看开发者指南获取更多技术细节
|
||||
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
- **社区讨论**: [GitHub Discussions](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
||||
|
||||
---
|
||||
|
||||
**提示**:
|
||||
|
||||
- 生产环境推荐使用方式二(手动安装)的稳定版本
|
||||
- 开发和测试推荐使用方式三(开发者安装)
|
||||
- 快速体验和演示推荐使用方式一(一键安装)
|
||||
- **大多数用户**推荐使用方式二(手动安装)的 `unilabos` 标准版
|
||||
- **开发者**推荐使用方式三(开发者安装),安装 `unilabos-env` 后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖
|
||||
- **仿真/可视化**推荐安装 `unilabos-full` 完整版
|
||||
- **快速体验和演示**推荐使用方式一(一键安装)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.12
|
||||
version: 0.10.18
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
@@ -17,7 +17,7 @@ build:
|
||||
- bash $SRC_DIR/build_ament_cmake.sh
|
||||
|
||||
about:
|
||||
repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: BSD-3-Clause
|
||||
description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS."
|
||||
|
||||
@@ -25,7 +25,7 @@ requirements:
|
||||
build:
|
||||
- ${{ compiler('cxx') }}
|
||||
- ${{ compiler('c') }}
|
||||
- python ==3.11.11
|
||||
- python ==3.11.14
|
||||
- numpy
|
||||
- if: build_platform != target_platform
|
||||
then:
|
||||
@@ -63,14 +63,14 @@ requirements:
|
||||
- robostack-staging::ros-humble-rosidl-default-generators
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.6
|
||||
- robostack-staging::ros2-distro-mutex=0.7
|
||||
run:
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-ros-workspace
|
||||
- robostack-staging::ros-humble-rosidl-default-runtime
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.6
|
||||
- robostack-staging::ros2-distro-mutex=0.7
|
||||
- if: osx and x86_64
|
||||
then:
|
||||
- __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.12"
|
||||
version: "0.10.18"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
@@ -85,7 +85,7 @@ Verification:
|
||||
-------------
|
||||
|
||||
The verify_installation.py script will check:
|
||||
- Python version (3.11.11)
|
||||
- Python version (3.11.14)
|
||||
- ROS2 rclpy installation
|
||||
- UniLabOS installation and dependencies
|
||||
|
||||
@@ -104,7 +104,7 @@ Build Information:
|
||||
|
||||
Branch: {branch}
|
||||
Platform: {platform}
|
||||
Python: 3.11.11
|
||||
Python: 3.11.14
|
||||
Date: {build_date}
|
||||
|
||||
Troubleshooting:
|
||||
@@ -126,7 +126,7 @@ If installation fails:
|
||||
For more help:
|
||||
- Documentation: docs/user_guide/installation.md
|
||||
- Quick Start: QUICK_START_CONDA_PACK.md
|
||||
- Issues: https://github.com/dptech-corp/Uni-Lab-OS/issues
|
||||
- Issues: https://github.com/deepmodeling/Uni-Lab-OS/issues
|
||||
|
||||
License:
|
||||
--------
|
||||
@@ -134,7 +134,7 @@ License:
|
||||
UniLabOS is licensed under GPL-3.0-only.
|
||||
See LICENSE file for details.
|
||||
|
||||
Repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||
Repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
"""
|
||||
|
||||
return readme
|
||||
|
||||
214
scripts/dev_install.py
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Development installation script for UniLabOS.
|
||||
Auto-detects Chinese locale and uses appropriate mirror.
|
||||
|
||||
Usage:
|
||||
python scripts/dev_install.py
|
||||
python scripts/dev_install.py --no-mirror # Force no mirror
|
||||
python scripts/dev_install.py --china # Force China mirror
|
||||
python scripts/dev_install.py --skip-deps # Skip pip dependencies installation
|
||||
|
||||
Flow:
|
||||
1. pip install -e . (install unilabos in editable mode)
|
||||
2. Detect Chinese locale
|
||||
3. Use uv to install pip dependencies from requirements.txt
|
||||
4. Special packages (like pylabrobot) are handled by environment_check.py at runtime
|
||||
"""
|
||||
|
||||
import locale
|
||||
import subprocess
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Tsinghua mirror URL
|
||||
TSINGHUA_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
|
||||
|
||||
def is_chinese_locale() -> bool:
|
||||
"""
|
||||
Detect if system is in Chinese locale.
|
||||
Same logic as EnvironmentChecker._is_chinese_locale()
|
||||
"""
|
||||
try:
|
||||
lang = locale.getdefaultlocale()[0]
|
||||
if lang and ("zh" in lang.lower() or "chinese" in lang.lower()):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def run_command(cmd: list, description: str, retry: int = 2) -> bool:
|
||||
"""Run command with retry support."""
|
||||
print(f"[INFO] {description}")
|
||||
print(f"[CMD] {' '.join(cmd)}")
|
||||
|
||||
for attempt in range(retry + 1):
|
||||
try:
|
||||
result = subprocess.run(cmd, check=True, timeout=600)
|
||||
print(f"[OK] {description}")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
if attempt < retry:
|
||||
print(f"[WARN] Attempt {attempt + 1} failed, retrying...")
|
||||
else:
|
||||
print(f"[ERROR] {description} failed: {e}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[ERROR] {description} timed out")
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def install_editable(project_root: Path, use_mirror: bool) -> bool:
|
||||
"""Install unilabos in editable mode using pip."""
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-e", str(project_root)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing unilabos in editable mode")
|
||||
|
||||
|
||||
def install_requirements_uv(requirements_file: Path, use_mirror: bool) -> bool:
|
||||
"""Install pip dependencies using uv (installed via conda-forge::uv)."""
|
||||
cmd = ["uv", "pip", "install", "-r", str(requirements_file)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing pip dependencies with uv", retry=2)
|
||||
|
||||
|
||||
def install_requirements_pip(requirements_file: Path, use_mirror: bool) -> bool:
|
||||
"""Fallback: Install pip dependencies using pip."""
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing pip dependencies with pip", retry=2)
|
||||
|
||||
|
||||
def check_uv_available() -> bool:
|
||||
"""Check if uv is available (installed via conda-forge::uv)."""
|
||||
try:
|
||||
subprocess.run(["uv", "--version"], capture_output=True, check=True)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Development installation script for UniLabOS")
|
||||
parser.add_argument("--china", action="store_true", help="Force use China mirror (Tsinghua)")
|
||||
parser.add_argument("--no-mirror", action="store_true", help="Force use default PyPI (no mirror)")
|
||||
parser.add_argument(
|
||||
"--skip-deps", action="store_true", help="Skip pip dependencies installation (only install unilabos)"
|
||||
)
|
||||
parser.add_argument("--use-pip", action="store_true", help="Use pip instead of uv for dependencies")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine project root
|
||||
script_dir = Path(__file__).parent
|
||||
project_root = script_dir.parent
|
||||
requirements_file = project_root / "unilabos" / "utils" / "requirements.txt"
|
||||
|
||||
if not (project_root / "setup.py").exists():
|
||||
print(f"[ERROR] setup.py not found in {project_root}")
|
||||
sys.exit(1)
|
||||
|
||||
print("=" * 60)
|
||||
print("UniLabOS Development Installation")
|
||||
print("=" * 60)
|
||||
print(f"Project root: {project_root}")
|
||||
print()
|
||||
|
||||
# Determine mirror usage based on locale
|
||||
if args.no_mirror:
|
||||
use_mirror = False
|
||||
print("[INFO] Mirror disabled by --no-mirror flag")
|
||||
elif args.china:
|
||||
use_mirror = True
|
||||
print("[INFO] China mirror enabled by --china flag")
|
||||
else:
|
||||
use_mirror = is_chinese_locale()
|
||||
if use_mirror:
|
||||
print("[INFO] Chinese locale detected, using Tsinghua mirror")
|
||||
else:
|
||||
print("[INFO] Non-Chinese locale detected, using default PyPI")
|
||||
|
||||
print()
|
||||
|
||||
# Step 1: Install unilabos in editable mode
|
||||
print("[STEP 1] Installing unilabos in editable mode...")
|
||||
if not install_editable(project_root, use_mirror):
|
||||
print("[ERROR] Failed to install unilabos")
|
||||
print()
|
||||
print("Manual fallback:")
|
||||
if use_mirror:
|
||||
print(f" pip install -e {project_root} -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(f" pip install -e {project_root}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
|
||||
# Step 2: Install pip dependencies
|
||||
if args.skip_deps:
|
||||
print("[INFO] Skipping pip dependencies installation (--skip-deps)")
|
||||
else:
|
||||
print("[STEP 2] Installing pip dependencies...")
|
||||
|
||||
if not requirements_file.exists():
|
||||
print(f"[WARN] Requirements file not found: {requirements_file}")
|
||||
print("[INFO] Skipping dependencies installation")
|
||||
else:
|
||||
# Try uv first (faster), fallback to pip
|
||||
if args.use_pip:
|
||||
print("[INFO] Using pip (--use-pip flag)")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
elif check_uv_available():
|
||||
print("[INFO] Using uv (installed via conda-forge::uv)")
|
||||
success = install_requirements_uv(requirements_file, use_mirror)
|
||||
if not success:
|
||||
print("[WARN] uv failed, falling back to pip...")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
else:
|
||||
print("[WARN] uv not available (should be installed via: mamba install conda-forge::uv)")
|
||||
print("[INFO] Falling back to pip...")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
|
||||
if not success:
|
||||
print()
|
||||
print("[WARN] Failed to install some dependencies automatically.")
|
||||
print("You can manually install them:")
|
||||
if use_mirror:
|
||||
print(f" uv pip install -r {requirements_file} -i {TSINGHUA_MIRROR}")
|
||||
print(" or:")
|
||||
print(f" pip install -r {requirements_file} -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(f" uv pip install -r {requirements_file}")
|
||||
print(" or:")
|
||||
print(f" pip install -r {requirements_file}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Installation complete!")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Note: Some special packages (like pylabrobot) are installed")
|
||||
print("automatically at runtime by unilabos if needed.")
|
||||
print()
|
||||
print("Verify installation:")
|
||||
print(' python -c "import unilabos; print(unilabos.__version__)"')
|
||||
print()
|
||||
print("If you encounter issues, you can manually install dependencies:")
|
||||
if use_mirror:
|
||||
print(f" uv pip install -r unilabos/utils/requirements.txt -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(" uv pip install -r unilabos/utils/requirements.txt")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.12',
|
||||
version='0.10.18',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
7
tests/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
测试包根目录。
|
||||
|
||||
让 `tests.*` 模块可以被正常 import(例如给 `unilabos` 下的测试入口使用)。
|
||||
"""
|
||||
|
||||
|
||||
1
tests/devices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
tests/devices/liquid_handling/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
液体处理设备相关测试。
|
||||
"""
|
||||
|
||||
|
||||
505
tests/devices/liquid_handling/test_transfer_liquid.py
Normal file
@@ -0,0 +1,505 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyContainer:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyContainer({self.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyTipSpot({self.name})"
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
||||
|
||||
def __init__(self, channel_num: int = 8):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"aspirate",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"dispense",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
||||
|
||||
async def touch_tip(self, targets):
|
||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
||||
self.calls.append(("touch_tip", {"targets": targets}))
|
||||
|
||||
async def mix(self, targets, mix_time=None, mix_vol=None, height_to_bottom=None, offsets=None, mix_rate=None, none_keys=None):
|
||||
self.calls.append(
|
||||
(
|
||||
"mix",
|
||||
{
|
||||
"targets": targets,
|
||||
"mix_time": mix_time,
|
||||
"mix_vol": mix_vol,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_basic_calls():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 2, 3],
|
||||
dis_vols=[4, 5, 6],
|
||||
mix_times=None, # 应该仍能执行(不 mix)
|
||||
)
|
||||
)
|
||||
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
||||
|
||||
# 每次 aspirate/dispense 都是单孔列表
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [sources[0]]
|
||||
assert aspirates[0]["vols"] == [1.0]
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[2]["resources"] == [targets[2]]
|
||||
assert dispenses[2]["vols"] == [6.0]
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(16))
|
||||
|
||||
source = DummyContainer("S0")
|
||||
target = DummyContainer("T0")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5],
|
||||
dis_vols=[5],
|
||||
mix_stage="before",
|
||||
mix_times=1,
|
||||
mix_vol=3,
|
||||
)
|
||||
)
|
||||
|
||||
names = [name for name, _ in lh.calls]
|
||||
assert names.count("mix") == 1
|
||||
assert names.index("mix") < names.index("aspirate")
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_groups_by_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = list(range(1, 17))
|
||||
dis_vols = list(range(101, 117))
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0, # 触发逻辑但不 mix
|
||||
)
|
||||
)
|
||||
|
||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == 2
|
||||
assert len(dispenses) == 2
|
||||
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
||||
assert dispenses[1]["resources"] == targets[8:16]
|
||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
||||
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(512))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = [i + 1 for i in range(16)]
|
||||
dis_vols = [200 + i for i in range(16)]
|
||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
||||
offsets = [f"offset_{i}" for i in range(16)]
|
||||
liquid_heights = [i * 0.5 for i in range(16)]
|
||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_heights,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
|
||||
for batch_idx in range(2):
|
||||
start = batch_idx * 8
|
||||
end = start + 8
|
||||
asp_call = aspirates[batch_idx]
|
||||
dis_call = dispenses[batch_idx]
|
||||
assert asp_call["resources"] == sources[start:end]
|
||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
||||
assert asp_call["offsets"] == offsets[start:end]
|
||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
||||
assert dis_call["offsets"] == offsets[start:end]
|
||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(1024))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
||||
asp_vols = [i + 1 for i in range(32)]
|
||||
dis_vols = [300 + i for i in range(32)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(pick_calls) == 4
|
||||
assert len(aspirates) == len(dispenses) == 4
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[-1]["resources"] == sources[24:32]
|
||||
assert dispenses[0]["resources"] == targets[0:8]
|
||||
assert dispenses[-1]["resources"] == targets[24:32]
|
||||
|
||||
|
||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
dis_vols = [10, 20, 30] # sum=60
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 1
|
||||
assert aspirates[0]["resources"] == [source]
|
||||
assert aspirates[0]["vols"] == [60.0]
|
||||
assert aspirates[0]["use_channels"] == [0]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_one_to_many_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
||||
dis_vols = [i + 1 for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [source] * 8
|
||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[0]["resources"] == targets
|
||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
||||
assert all(d["resources"] == [target] for d in dispenses)
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
target = DummyContainer("T")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5, 6],
|
||||
dis_vols=1,
|
||||
mix_stage="before",
|
||||
mix_times=2,
|
||||
mix_vol=4,
|
||||
)
|
||||
)
|
||||
|
||||
names = [name for name, _ in lh.calls]
|
||||
assert names[0] == "mix"
|
||||
assert names.count("mix") == 1
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
dis_vols = [1, 2, 3]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols, # 比例模式
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [10 + i for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert aspirates[0]["resources"] == sources
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
||||
assert dispenses[0]["resources"] == [target] * 8
|
||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
||||
|
||||
|
||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,9 +2,8 @@ import pytest
|
||||
import json
|
||||
import os
|
||||
|
||||
from pylabrobot.resources import Resource as ResourcePLR
|
||||
from unilabos.resources.graphio import resource_bioyond_to_plr
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||
@@ -11,10 +11,10 @@ import os
|
||||
# 添加项目根目录到路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
|
||||
# 导入测试模块
|
||||
from test.ros.msgs.test_basic import TestBasicFunctionality
|
||||
from test.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
|
||||
from test.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
|
||||
# 导入测试模块(统一从 tests 包获取)
|
||||
from tests.ros.msgs.test_basic import TestBasicFunctionality
|
||||
from tests.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
|
||||
from tests.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
|
||||
|
||||
|
||||
def run_tests():
|
||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
213
tests/workflow/test.json
Normal file
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"workflow": [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_1",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_2",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_3",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_4",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_5",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_6",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set_2",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set_3",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"reagent": {
|
||||
"Liquid_1": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"Liquid_4": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"dest_set": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"Liquid_2": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"Liquid_5": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"dest_set_2": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"Liquid_3": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"Liquid_6": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"dest_set_3": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"cell_lines": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
},
|
||||
"cell_lines_2": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
},
|
||||
"cell_lines_3": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.12"
|
||||
__version__ = "0.10.18"
|
||||
|
||||
6
unilabos/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Entry point for `python -m unilabos`."""
|
||||
|
||||
from unilabos.app.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
import threading
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import networkx as nx
|
||||
import yaml
|
||||
|
||||
@@ -17,9 +17,14 @@ unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
if unilabos_dir not in sys.path:
|
||||
sys.path.append(unilabos_dir)
|
||||
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
|
||||
# Global restart flags (used by ws_client and web/server)
|
||||
_restart_requested: bool = False
|
||||
_restart_reason: str = ""
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
@@ -156,6 +161,23 @@ def parse_args():
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no_update_feedback",
|
||||
action="store_true",
|
||||
help="Disable sending update feedback to server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
@@ -189,6 +211,12 @@ def parse_args():
|
||||
default=False,
|
||||
help="Whether to publish the workflow (default: False)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--description",
|
||||
type=str,
|
||||
default="",
|
||||
help="Workflow description, used when publishing the workflow",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -201,7 +229,10 @@ def main():
|
||||
args_dict = vars(args)
|
||||
|
||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||
if not args_dict.get("skip_env_check", False):
|
||||
skip_env_check = args_dict.get("skip_env_check", False)
|
||||
check_mode = args_dict.get("check_mode", False)
|
||||
|
||||
if not skip_env_check:
|
||||
from unilabos.utils.environment_check import check_environment
|
||||
|
||||
if not check_environment(auto_install=True):
|
||||
@@ -212,49 +243,75 @@ def main():
|
||||
|
||||
# 加载配置文件,优先加载config,然后从env读取
|
||||
config_path = args_dict.get("config")
|
||||
if os.getcwd().endswith("unilabos_data"):
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
else:
|
||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||
|
||||
if args_dict.get("working_dir"):
|
||||
working_dir = args_dict.get("working_dir", "")
|
||||
if config_path and not os.path.exists(config_path):
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
if not os.path.exists(config_path):
|
||||
print_status(
|
||||
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
|
||||
"error",
|
||||
)
|
||||
os._exit(1)
|
||||
# === 解析 working_dir ===
|
||||
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
|
||||
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
|
||||
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
|
||||
raw_working_dir = args_dict.get("working_dir")
|
||||
if raw_working_dir:
|
||||
working_dir = os.path.abspath(raw_working_dir)
|
||||
elif config_path and os.path.exists(config_path):
|
||||
working_dir = os.path.dirname(config_path)
|
||||
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
elif not config_path and (
|
||||
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
|
||||
):
|
||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||
if input() != "n":
|
||||
os.makedirs(working_dir, exist_ok=True)
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
shutil.copy(
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
|
||||
)
|
||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||
working_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
else:
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
|
||||
# unilabos_data 子目录自动检测
|
||||
if os.path.basename(working_dir) != "unilabos_data":
|
||||
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
|
||||
if os.path.isdir(unilabos_data_sub):
|
||||
working_dir = unilabos_data_sub
|
||||
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
|
||||
# 未显式指定路径,默认使用 cwd/unilabos_data
|
||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||
|
||||
# === 解析 config_path ===
|
||||
if config_path and not os.path.exists(config_path):
|
||||
# config_path 传入但不存在,尝试在 working_dir 中查找
|
||||
candidate = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(candidate):
|
||||
config_path = candidate
|
||||
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
|
||||
else:
|
||||
print_status(
|
||||
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py,"
|
||||
f"请通过 --config 传入 local_config.py 文件路径",
|
||||
"error",
|
||||
)
|
||||
os._exit(1)
|
||||
# 加载配置文件
|
||||
elif not config_path:
|
||||
# 规则3: 未传入 config_path,尝试 working_dir/local_config.py
|
||||
candidate = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(candidate):
|
||||
config_path = candidate
|
||||
print_status(f"发现本地配置文件: {config_path}", "info")
|
||||
else:
|
||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||
if check_mode or input() != "n":
|
||||
os.makedirs(working_dir, exist_ok=True)
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
shutil.copy(
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
|
||||
config_path,
|
||||
)
|
||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||
else:
|
||||
os._exit(1)
|
||||
|
||||
# 加载配置文件 (check_mode 跳过)
|
||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||
load_config_from_file(config_path)
|
||||
if not check_mode:
|
||||
load_config_from_file(config_path)
|
||||
|
||||
# 根据配置重新设置日志级别
|
||||
from unilabos.utils.log import configure_logger, logger
|
||||
|
||||
if hasattr(BasicConfig, "log_level"):
|
||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
if file_path is not None:
|
||||
logger.info(f"[LOG_FILE] {file_path}")
|
||||
|
||||
if args.addr != parser.get_default("addr"):
|
||||
if args.addr == "test":
|
||||
@@ -297,11 +354,16 @@ def main():
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
||||
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||
BasicConfig.communication_protocol = "websocket"
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = platform.node()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
BasicConfig.machine_name = machine_name
|
||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||
BasicConfig.check_mode = check_mode
|
||||
|
||||
from unilabos.resources.graphio import (
|
||||
read_node_link_json,
|
||||
@@ -315,15 +377,19 @@ def main():
|
||||
from unilabos.app.web import start_server
|
||||
from unilabos.app.register import register_devices_and_resources
|
||||
from unilabos.resources.graphio import modify_to_backend_format
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry
|
||||
)
|
||||
# 注册表 - check_mode 时强制启用 complete_registry
|
||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||
lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry)
|
||||
|
||||
# Check mode: complete_registry 完成后直接退出,git diff 检测由 CI workflow 执行
|
||||
if check_mode:
|
||||
print_status("Check mode: complete_registry 完成,退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
@@ -418,7 +484,7 @@ def main():
|
||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||
if request_startup_json and "nodes" in request_startup_json:
|
||||
print_status("开始同步远端物料到本地...", "info")
|
||||
remote_tree_set = ResourceTreeSet.from_raw_list(request_startup_json["nodes"])
|
||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||
print_status("远端物料同步完成", "info")
|
||||
|
||||
@@ -497,13 +563,19 @@ def main():
|
||||
time.sleep(1)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
restart_requested = start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
if restart_requested:
|
||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||
cleanup_for_restart()
|
||||
return
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
|
||||
# 启动服务器(默认支持WebSocket触发重启)
|
||||
restart_requested = start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ class JobAddReq(BaseModel):
|
||||
action_type: str = Field(
|
||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||
)
|
||||
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
|
||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||
|
||||
@@ -38,9 +38,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||
|
||||
@@ -51,9 +51,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||
|
||||
|
||||
176
unilabos/app/utils.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
UniLabOS 应用工具函数
|
||||
|
||||
提供清理、重启等工具函数
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def patch_rclpy_dll_windows():
|
||||
"""在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁"""
|
||||
if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"):
|
||||
return
|
||||
try:
|
||||
import rclpy
|
||||
|
||||
return
|
||||
except ImportError as e:
|
||||
if not str(e).startswith("DLL load failed"):
|
||||
return
|
||||
cp = os.environ["CONDA_PREFIX"]
|
||||
impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py")
|
||||
pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd"))
|
||||
if not os.path.exists(impl) or not pyd:
|
||||
return
|
||||
with open(impl, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/")
|
||||
patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n'
|
||||
shutil.copy2(impl, impl + ".bak")
|
||||
with open(impl, "w", encoding="utf-8") as f:
|
||||
f.write(patch + content)
|
||||
|
||||
|
||||
patch_rclpy_dll_windows()
|
||||
|
||||
import gc
|
||||
import threading
|
||||
import time
|
||||
|
||||
from unilabos.utils.banner_print import print_status
|
||||
|
||||
|
||||
def cleanup_for_restart() -> bool:
|
||||
"""
|
||||
Clean up all resources for restart without exiting the process.
|
||||
|
||||
This function prepares the system for re-initialization by:
|
||||
1. Stopping all communication clients
|
||||
2. Destroying ROS nodes
|
||||
3. Resetting singletons
|
||||
4. Waiting for threads to finish
|
||||
|
||||
Returns:
|
||||
bool: True if cleanup was successful, False otherwise
|
||||
"""
|
||||
print_status("[Restart] Starting cleanup for restart...", "info")
|
||||
|
||||
# Step 1: Stop WebSocket communication client
|
||||
print_status("[Restart] Step 1: Stopping WebSocket client...", "info")
|
||||
try:
|
||||
from unilabos.app.communication import get_communication_client
|
||||
|
||||
comm_client = get_communication_client()
|
||||
if comm_client is not None:
|
||||
comm_client.stop()
|
||||
print_status("[Restart] WebSocket client stopped", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error stopping WebSocket: {e}", "warning")
|
||||
|
||||
# Step 2: Get HostNode and cleanup ROS
|
||||
print_status("[Restart] Step 2: Cleaning up ROS nodes...", "info")
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
import rclpy
|
||||
from rclpy.timer import Timer
|
||||
|
||||
host_instance = HostNode.get_instance(timeout=5)
|
||||
if host_instance is not None:
|
||||
print_status(f"[Restart] Found HostNode: {host_instance.device_id}", "info")
|
||||
|
||||
# Gracefully shutdown background threads
|
||||
print_status("[Restart] Shutting down background threads...", "info")
|
||||
HostNode.shutdown_background_threads(timeout=5.0)
|
||||
print_status("[Restart] Background threads shutdown complete", "info")
|
||||
|
||||
# Stop discovery timer
|
||||
if hasattr(host_instance, "_discovery_timer") and isinstance(host_instance._discovery_timer, Timer):
|
||||
host_instance._discovery_timer.cancel()
|
||||
print_status("[Restart] Discovery timer cancelled", "info")
|
||||
|
||||
# Destroy device nodes
|
||||
device_count = len(host_instance.devices_instances)
|
||||
print_status(f"[Restart] Destroying {device_count} device instances...", "info")
|
||||
for device_id, device_node in list(host_instance.devices_instances.items()):
|
||||
try:
|
||||
if hasattr(device_node, "ros_node_instance") and device_node.ros_node_instance is not None:
|
||||
device_node.ros_node_instance.destroy_node()
|
||||
print_status(f"[Restart] Device {device_id} destroyed", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error destroying device {device_id}: {e}", "warning")
|
||||
|
||||
# Clear devices instances
|
||||
host_instance.devices_instances.clear()
|
||||
host_instance.devices_names.clear()
|
||||
|
||||
# Destroy host node
|
||||
try:
|
||||
host_instance.destroy_node()
|
||||
print_status("[Restart] HostNode destroyed", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error destroying HostNode: {e}", "warning")
|
||||
|
||||
# Reset HostNode state
|
||||
HostNode.reset_state()
|
||||
print_status("[Restart] HostNode state reset", "info")
|
||||
|
||||
# Shutdown executor first (to stop executor.spin() gracefully)
|
||||
if hasattr(rclpy, "__executor") and rclpy.__executor is not None:
|
||||
try:
|
||||
rclpy.__executor.shutdown()
|
||||
rclpy.__executor = None # Clear for restart
|
||||
print_status("[Restart] ROS executor shutdown complete", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error shutting down executor: {e}", "warning")
|
||||
|
||||
# Shutdown rclpy
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
print_status("[Restart] rclpy shutdown complete", "info")
|
||||
|
||||
except ImportError as e:
|
||||
print_status(f"[Restart] ROS modules not available: {e}", "warning")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error in ROS cleanup: {e}", "warning")
|
||||
return False
|
||||
|
||||
# Step 3: Reset communication client singleton
|
||||
print_status("[Restart] Step 3: Resetting singletons...", "info")
|
||||
try:
|
||||
from unilabos.app import communication
|
||||
|
||||
if hasattr(communication, "_communication_client"):
|
||||
communication._communication_client = None
|
||||
print_status("[Restart] Communication client singleton reset", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error resetting communication singleton: {e}", "warning")
|
||||
|
||||
# Step 4: Wait for threads to finish
|
||||
print_status("[Restart] Step 4: Waiting for threads to finish...", "info")
|
||||
time.sleep(3) # Give threads time to finish
|
||||
|
||||
# Check remaining threads
|
||||
remaining_threads = []
|
||||
for t in threading.enumerate():
|
||||
if t.name != "MainThread" and t.is_alive():
|
||||
remaining_threads.append(t.name)
|
||||
|
||||
if remaining_threads:
|
||||
print_status(
|
||||
f"[Restart] Warning: {len(remaining_threads)} threads still running: {remaining_threads}", "warning"
|
||||
)
|
||||
else:
|
||||
print_status("[Restart] All threads stopped", "info")
|
||||
|
||||
# Step 5: Force garbage collection
|
||||
print_status("[Restart] Step 5: Running garbage collection...", "info")
|
||||
gc.collect()
|
||||
gc.collect() # Run twice for weak references
|
||||
print_status("[Restart] Garbage collection complete", "info")
|
||||
|
||||
print_status("[Restart] Cleanup complete. Ready for re-initialization.", "info")
|
||||
return True
|
||||
@@ -3,15 +3,13 @@ HTTP客户端模块
|
||||
|
||||
提供与远程服务器通信的客户端功能,只有host需要用
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from threading import Thread
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import requests
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils.log import info
|
||||
from unilabos.config.config import HTTPConfig, BasicConfig
|
||||
from unilabos.utils import logger
|
||||
@@ -292,10 +290,17 @@ class HTTPClient:
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
compressed_body = gzip.compress(
|
||||
json.dumps(registry_data, ensure_ascii=False, default=str).encode("utf-8")
|
||||
)
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/resource",
|
||||
json=registry_data,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
data=compressed_body,
|
||||
headers={
|
||||
"Authorization": f"Lab {self.auth}",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code not in [200, 201]:
|
||||
@@ -345,9 +350,10 @@ class HTTPClient:
|
||||
edges: List[Dict[str, Any]],
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
description: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器
|
||||
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
@@ -357,13 +363,12 @@ class HTTPClient:
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
description: 工作流描述,发布时使用
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
"""
|
||||
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
|
||||
payload = {
|
||||
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
|
||||
"name": name,
|
||||
"data": {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
@@ -371,7 +376,6 @@ class HTTPClient:
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
"published": published,
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
@@ -392,11 +396,51 @@ class HTTPClient:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"导入工作流失败: {response.text}")
|
||||
return res
|
||||
# 导入成功后,如果需要发布则额外发起发布请求
|
||||
if published:
|
||||
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
|
||||
publish_res = self.workflow_publish(imported_uuid, description)
|
||||
res["publish_result"] = publish_res
|
||||
return res
|
||||
else:
|
||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
发布工作流
|
||||
|
||||
Args:
|
||||
workflow_uuid: 工作流UUID
|
||||
description: 工作流描述
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据
|
||||
"""
|
||||
payload = {
|
||||
"uuid": workflow_uuid,
|
||||
"description": description,
|
||||
"published": True,
|
||||
}
|
||||
logger.info(f"正在发布工作流: {workflow_uuid}")
|
||||
response = requests.patch(
|
||||
f"{self.remote_addr}/lab/workflow/owner",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"发布工作流失败: {response.text}")
|
||||
else:
|
||||
logger.info(f"工作流发布成功: {workflow_uuid}")
|
||||
return res
|
||||
else:
|
||||
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
|
||||
# 创建默认客户端实例
|
||||
http_client = HTTPClient()
|
||||
|
||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
@@ -327,6 +327,7 @@ def job_add(req: JobAddReq) -> JobData:
|
||||
queue_item,
|
||||
action_type=action_type,
|
||||
action_kwargs=action_args,
|
||||
sample_material=req.sample_material,
|
||||
server_info=server_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ Web服务器模块
|
||||
|
||||
import webbrowser
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import Response
|
||||
@@ -96,7 +95,7 @@ def setup_server() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None:
|
||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> bool:
|
||||
"""
|
||||
启动服务器
|
||||
|
||||
@@ -104,7 +103,14 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
host: 服务器主机
|
||||
port: 服务器端口
|
||||
open_browser: 是否自动打开浏览器
|
||||
|
||||
Returns:
|
||||
bool: True if restart was requested, False otherwise
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from uvicorn import Config, Server
|
||||
|
||||
# 设置服务器
|
||||
setup_server()
|
||||
|
||||
@@ -123,7 +129,37 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
|
||||
# 启动服务器
|
||||
info(f"[Web] 启动FastAPI服务器: {host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_config=log_config)
|
||||
|
||||
# 使用支持重启的模式
|
||||
config = Config(app=app, host=host, port=port, log_config=log_config)
|
||||
server = Server(config)
|
||||
|
||||
# 启动服务器线程
|
||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||
server_thread.start()
|
||||
|
||||
info("[Web] Server started, monitoring for restart requests...")
|
||||
|
||||
# 监控重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
while server_thread.is_alive():
|
||||
if hasattr(main_module, "_restart_requested") and main_module._restart_requested:
|
||||
info(
|
||||
f"[Web] Restart requested via WebSocket, reason: {getattr(main_module, '_restart_reason', 'unknown')}"
|
||||
)
|
||||
main_module._restart_requested = False
|
||||
|
||||
# 停止服务器
|
||||
server.should_exit = True
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
info("[Web] Server stopped, ready for restart")
|
||||
return True
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# 当脚本直接运行时启动服务器
|
||||
|
||||
@@ -23,7 +23,7 @@ from typing import Optional, Dict, Any, List
|
||||
from urllib.parse import urlparse
|
||||
from enum import Enum
|
||||
|
||||
from jedi.inference.gradual.typing import TypedDict
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.app.model import JobAddReq
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
@@ -76,6 +76,7 @@ class JobInfo:
|
||||
start_time: float
|
||||
last_update_time: float = field(default_factory=time.time)
|
||||
ready_timeout: Optional[float] = None # READY状态的超时时间
|
||||
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
|
||||
|
||||
def update_timestamp(self):
|
||||
"""更新最后更新时间"""
|
||||
@@ -127,6 +128,15 @@ class DeviceActionManager:
|
||||
# 总是将job添加到all_jobs中
|
||||
self.all_jobs[job_info.job_id] = job_info
|
||||
|
||||
# always_free的动作不受排队限制,直接设为READY
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.READY
|
||||
job_info.update_timestamp()
|
||||
job_info.set_ready_timeout(10)
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
|
||||
return True
|
||||
|
||||
# 检查是否有正在执行或准备执行的任务
|
||||
if device_key in self.active_jobs:
|
||||
# 有正在执行或准备执行的任务,加入队列
|
||||
@@ -154,7 +164,7 @@ class DeviceActionManager:
|
||||
job_info.set_ready_timeout(10) # 设置10秒超时
|
||||
self.active_jobs[device_key] = job_info
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||
return True
|
||||
|
||||
def start_job(self, job_id: str) -> bool:
|
||||
@@ -176,11 +186,15 @@ class DeviceActionManager:
|
||||
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
||||
return False
|
||||
|
||||
# 检查设备上是否是这个job
|
||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||
return False
|
||||
# always_free的job不需要检查active_jobs
|
||||
if not job_info.always_free:
|
||||
# 检查设备上是否是这个job
|
||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||
job_log = format_job_log(
|
||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||
)
|
||||
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||
return False
|
||||
|
||||
# 开始执行任务,将状态从READY转换为STARTED
|
||||
job_info.status = JobStatus.STARTED
|
||||
@@ -203,6 +217,13 @@ class DeviceActionManager:
|
||||
job_info = self.all_jobs[job_id]
|
||||
device_key = job_info.device_action_key
|
||||
|
||||
# always_free的job直接清理,不影响队列
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.ENDED
|
||||
job_info.update_timestamp()
|
||||
del self.all_jobs[job_id]
|
||||
return None
|
||||
|
||||
# 移除活跃任务
|
||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||
del self.active_jobs[device_key]
|
||||
@@ -210,8 +231,9 @@ class DeviceActionManager:
|
||||
job_info.update_timestamp()
|
||||
# 从all_jobs中移除已结束的job
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||
# job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
# logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||
pass
|
||||
else:
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
||||
@@ -227,15 +249,20 @@ class DeviceActionManager:
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||
return next_job
|
||||
|
||||
return None
|
||||
|
||||
def get_active_jobs(self) -> List[JobInfo]:
|
||||
"""获取所有正在执行的任务"""
|
||||
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
|
||||
with self.lock:
|
||||
return list(self.active_jobs.values())
|
||||
jobs = list(self.active_jobs.values())
|
||||
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
|
||||
for job in self.all_jobs.values():
|
||||
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
|
||||
jobs.append(job)
|
||||
return jobs
|
||||
|
||||
def get_queued_jobs(self) -> List[JobInfo]:
|
||||
"""获取所有排队中的任务"""
|
||||
@@ -260,6 +287,14 @@ class DeviceActionManager:
|
||||
job_info = self.all_jobs[job_id]
|
||||
device_key = job_info.device_action_key
|
||||
|
||||
# always_free的job直接清理
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.ENDED
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
|
||||
return True
|
||||
|
||||
# 如果是正在执行的任务
|
||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||
# 清理active job状态
|
||||
@@ -268,7 +303,7 @@ class DeviceActionManager:
|
||||
# 从all_jobs中移除
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
|
||||
# 启动下一个任务
|
||||
if device_key in self.device_queues and self.device_queues[device_key]:
|
||||
@@ -281,7 +316,7 @@ class DeviceActionManager:
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
return True
|
||||
|
||||
# 如果是排队中的任务
|
||||
@@ -295,7 +330,7 @@ class DeviceActionManager:
|
||||
job_log = format_job_log(
|
||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||
return True
|
||||
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
@@ -333,13 +368,18 @@ class DeviceActionManager:
|
||||
timeout_jobs = []
|
||||
|
||||
with self.lock:
|
||||
# 统计READY状态的任务数量
|
||||
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
|
||||
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
|
||||
ready_candidates = list(self.active_jobs.values())
|
||||
for job in self.all_jobs.values():
|
||||
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
|
||||
ready_candidates.append(job)
|
||||
|
||||
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
|
||||
if ready_jobs_count > 0:
|
||||
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
||||
|
||||
# 找到所有超时的READY任务(只检测,不处理)
|
||||
for job_info in self.active_jobs.values():
|
||||
for job_info in ready_candidates:
|
||||
if job_info.is_ready_timeout():
|
||||
timeout_jobs.append(job_info)
|
||||
job_log = format_job_log(
|
||||
@@ -359,7 +399,7 @@ class MessageProcessor:
|
||||
self.device_manager = device_manager
|
||||
self.queue_processor = None # 延迟设置
|
||||
self.websocket_client = None # 延迟设置
|
||||
self.session_id = ""
|
||||
self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id
|
||||
|
||||
# WebSocket连接
|
||||
self.websocket = None
|
||||
@@ -426,6 +466,7 @@ class MessageProcessor:
|
||||
async with websockets.connect(
|
||||
self.websocket_url,
|
||||
ssl=ssl_context,
|
||||
open_timeout=20,
|
||||
ping_interval=WSConfig.ping_interval,
|
||||
ping_timeout=10,
|
||||
additional_headers={
|
||||
@@ -457,6 +498,18 @@ class MessageProcessor:
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[MessageProcessor] Connection closed")
|
||||
self.connected = False
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] Connection timeout (attempt {self.reconnect_count + 1}), "
|
||||
f"server may be temporarily unavailable"
|
||||
)
|
||||
self.connected = False
|
||||
except websockets.exceptions.InvalidStatus as e:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] Server returned unexpected HTTP status {e.response.status_code}, "
|
||||
f"WebSocket endpoint may not be ready yet"
|
||||
)
|
||||
self.connected = False
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -465,18 +518,19 @@ class MessageProcessor:
|
||||
self.websocket = None
|
||||
|
||||
# 重连逻辑
|
||||
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
if not self.is_running:
|
||||
break
|
||||
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
self.reconnect_count += 1
|
||||
backoff = min(WSConfig.reconnect_interval * (2 ** (self.reconnect_count - 1)), 60)
|
||||
logger.info(
|
||||
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
|
||||
f"[MessageProcessor] Reconnecting in {backoff}s "
|
||||
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
)
|
||||
await asyncio.sleep(WSConfig.reconnect_interval)
|
||||
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
||||
await asyncio.sleep(backoff)
|
||||
else:
|
||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||
break
|
||||
else:
|
||||
self.reconnect_count -= 1
|
||||
|
||||
async def _message_handler(self):
|
||||
"""处理接收到的消息"""
|
||||
@@ -488,7 +542,20 @@ class MessageProcessor:
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
await self._process_message(data)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
@@ -531,7 +598,7 @@ class MessageProcessor:
|
||||
try:
|
||||
message_str = json.dumps(msg, ensure_ascii=False)
|
||||
await self.websocket.send(message_str)
|
||||
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||
# logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -554,12 +621,9 @@ class MessageProcessor:
|
||||
finally:
|
||||
logger.debug("[MessageProcessor] Send handler stopped")
|
||||
|
||||
async def _process_message(self, data: Dict[str, Any]):
|
||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
||||
"""处理收到的消息"""
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
|
||||
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||
logger.trace(f"[MessageProcessor] Processing message: {message_type}")
|
||||
|
||||
try:
|
||||
if message_type == "pong":
|
||||
@@ -571,14 +635,19 @@ class MessageProcessor:
|
||||
elif message_type == "cancel_action" or message_type == "cancel_task":
|
||||
await self._handle_cancel_action(message_data)
|
||||
elif message_type == "add_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "add")
|
||||
elif message_type == "update_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "update")
|
||||
elif message_type == "remove_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "remove")
|
||||
elif message_type == "session_id":
|
||||
self.session_id = message_data.get("session_id")
|
||||
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
# elif message_type == "session_id":
|
||||
# self.session_id = message_data.get("session_id")
|
||||
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "request_restart":
|
||||
await self._handle_request_restart(message_data)
|
||||
else:
|
||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||
|
||||
@@ -592,6 +661,24 @@ class MessageProcessor:
|
||||
if host_node:
|
||||
host_node.handle_pong_response(pong_data)
|
||||
|
||||
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
|
||||
"""检查该action是否标记为always_free,通过HostNode统一的_action_value_mappings查找"""
|
||||
try:
|
||||
host_node = HostNode.get_instance(0)
|
||||
if not host_node:
|
||||
return False
|
||||
# noinspection PyProtectedMember
|
||||
action_mappings = host_node._action_value_mappings.get(device_id)
|
||||
if not action_mappings:
|
||||
return False
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
return action_mappings[key].get("always_free", False)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
||||
"""处理query_action_state消息"""
|
||||
device_id = data.get("device_id", "")
|
||||
@@ -606,6 +693,9 @@ class MessageProcessor:
|
||||
|
||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||
|
||||
# 检查action是否为always_free
|
||||
action_always_free = self._check_action_always_free(device_id, action_name)
|
||||
|
||||
# 创建任务信息
|
||||
job_info = JobInfo(
|
||||
job_id=job_id,
|
||||
@@ -615,6 +705,7 @@ class MessageProcessor:
|
||||
device_action_key=device_action_key,
|
||||
status=JobStatus.QUEUE,
|
||||
start_time=time.time(),
|
||||
always_free=action_always_free,
|
||||
)
|
||||
|
||||
# 添加到设备管理器
|
||||
@@ -626,13 +717,13 @@ class MessageProcessor:
|
||||
await self._send_action_state_response(
|
||||
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
||||
)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||
else:
|
||||
# 需要排队
|
||||
await self._send_action_state_response(
|
||||
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
||||
)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} queued")
|
||||
logger.trace(f"[MessageProcessor] Job {job_log} queued")
|
||||
|
||||
# 通知QueueProcessor有新的队列更新
|
||||
if self.queue_processor:
|
||||
@@ -641,6 +732,8 @@ class MessageProcessor:
|
||||
async def _handle_job_start(self, data: Dict[str, Any]):
|
||||
"""处理job_start消息"""
|
||||
try:
|
||||
if not data.get("sample_material"):
|
||||
data["sample_material"] = {}
|
||||
req = JobAddReq(**data)
|
||||
|
||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||
@@ -672,6 +765,7 @@ class MessageProcessor:
|
||||
queue_item,
|
||||
action_type=req.action_type,
|
||||
action_kwargs=req.action_args,
|
||||
sample_material=req.sample_material,
|
||||
server_info=req.server_info,
|
||||
)
|
||||
|
||||
@@ -836,9 +930,7 @@ class MessageProcessor:
|
||||
device_action_groups[key_add] = []
|
||||
device_action_groups[key_add].append(item["uuid"])
|
||||
|
||||
logger.info(
|
||||
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||
)
|
||||
logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}")
|
||||
else:
|
||||
# 正常update
|
||||
key = (device_id, "update")
|
||||
@@ -852,11 +944,13 @@ class MessageProcessor:
|
||||
device_action_groups[key] = []
|
||||
device_action_groups[key].append(item["uuid"])
|
||||
|
||||
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||
logger.trace(
|
||||
f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}"
|
||||
)
|
||||
|
||||
# 为每个(device_id, action)创建独立的更新线程
|
||||
for (device_id, actual_action), items in device_action_groups.items():
|
||||
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
|
||||
logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
|
||||
|
||||
def _notify_resource_tree(dev_id, act, item_list):
|
||||
try:
|
||||
@@ -888,6 +982,51 @@ class MessageProcessor:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理重启请求
|
||||
|
||||
当LabGo发送request_restart时,执行清理并触发重启
|
||||
"""
|
||||
reason = data.get("reason", "unknown")
|
||||
delay = data.get("delay", 2) # 默认延迟2秒
|
||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||
|
||||
# 发送确认消息
|
||||
if self.websocket_client:
|
||||
await self.websocket_client.send_message(
|
||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||
)
|
||||
|
||||
# 设置全局重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
main_module._restart_requested = True
|
||||
main_module._restart_reason = reason
|
||||
|
||||
# 延迟后执行清理
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# 在新线程中执行清理,避免阻塞当前事件循环
|
||||
def do_cleanup():
|
||||
import time
|
||||
|
||||
time.sleep(0.5) # 给当前消息处理完成的时间
|
||||
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
|
||||
try:
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
|
||||
if cleanup_for_restart():
|
||||
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
|
||||
else:
|
||||
logger.error("[MessageProcessor] Cleanup failed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
|
||||
|
||||
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
|
||||
cleanup_thread.start()
|
||||
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
|
||||
|
||||
async def _send_action_state_response(
|
||||
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
|
||||
):
|
||||
@@ -1059,6 +1198,11 @@ class QueueProcessor:
|
||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
||||
|
||||
for job_info in queued_jobs:
|
||||
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
||||
# 此时不应再发送 busy/need_more,否则会覆盖已发出的 free=True 通知
|
||||
if job_info.status != JobStatus.QUEUE:
|
||||
continue
|
||||
|
||||
message = {
|
||||
"action": "report_action_state",
|
||||
"data": {
|
||||
@@ -1074,7 +1218,7 @@ class QueueProcessor:
|
||||
success = self.message_processor.send_message(message)
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
if success:
|
||||
logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||
logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||
else:
|
||||
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
||||
|
||||
@@ -1097,7 +1241,7 @@ class QueueProcessor:
|
||||
job_info.action_name,
|
||||
)
|
||||
|
||||
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
|
||||
# 结束任务,获取下一个可执行的任务
|
||||
next_job = self.device_manager.end_job(job_id)
|
||||
@@ -1117,8 +1261,8 @@ class QueueProcessor:
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||
logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||
|
||||
# 立即触发下一轮状态检查
|
||||
self.notify_queue_update()
|
||||
@@ -1240,7 +1384,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
logger.debug(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
|
||||
def publish_job_status(
|
||||
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
||||
@@ -1260,7 +1404,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||
|
||||
logger.info(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
|
||||
# 通知队列处理器job完成(包括timeout的job)
|
||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||
@@ -1282,7 +1426,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
self.message_processor.send_message(message)
|
||||
|
||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||
logger.debug(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||
|
||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||
"""发送ping消息"""
|
||||
@@ -1313,17 +1457,59 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
|
||||
|
||||
def publish_host_ready(self) -> None:
|
||||
"""发布host_node ready信号"""
|
||||
"""发布host_node ready信号,包含设备和动作信息"""
|
||||
if self.is_disabled or not self.is_connected():
|
||||
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
|
||||
return
|
||||
|
||||
# 收集设备信息
|
||||
devices = []
|
||||
machine_name = BasicConfig.machine_name
|
||||
|
||||
try:
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node:
|
||||
# 获取设备信息
|
||||
for device_id, namespace in host_node.devices_names.items():
|
||||
device_key = (
|
||||
f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
|
||||
)
|
||||
is_online = device_key in host_node._online_devices
|
||||
|
||||
# 获取设备的动作信息
|
||||
actions = {}
|
||||
for action_id, client in host_node._action_clients.items():
|
||||
# action_id 格式: /namespace/device_id/action_name
|
||||
if device_id in action_id:
|
||||
action_name = action_id.split("/")[-1]
|
||||
actions[action_name] = {
|
||||
"action_path": action_id,
|
||||
"action_type": str(type(client).__name__),
|
||||
}
|
||||
|
||||
devices.append(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"namespace": namespace,
|
||||
"device_key": device_key,
|
||||
"is_online": is_online,
|
||||
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
|
||||
"actions": actions,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Error collecting device info: {e}")
|
||||
|
||||
message = {
|
||||
"action": "host_node_ready",
|
||||
"data": {
|
||||
"status": "ready",
|
||||
"timestamp": time.time(),
|
||||
"machine_name": machine_name,
|
||||
"devices": devices,
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info("[WebSocketClient] Host node ready signal published")
|
||||
logger.info(f"[WebSocketClient] Host node ready signal published with {len(devices)} devices")
|
||||
|
||||
@@ -95,8 +95,29 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
||||
return total_volume
|
||||
|
||||
|
||||
def is_integrated_pump(node_name):
|
||||
return "pump" in node_name and "valve" in node_name
|
||||
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||
"""
|
||||
判断是否为泵阀一体设备
|
||||
"""
|
||||
class_lower = (node_class or "").lower()
|
||||
name_lower = (node_name or "").lower()
|
||||
|
||||
if "pump" not in class_lower and "pump" not in name_lower:
|
||||
return False
|
||||
|
||||
integrated_markers = [
|
||||
"valve",
|
||||
"pump_valve",
|
||||
"pumpvalve",
|
||||
"integrated",
|
||||
"transfer_pump",
|
||||
]
|
||||
|
||||
for marker in integrated_markers:
|
||||
if marker in class_lower or marker in name_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def find_connected_pump(G, valve_node):
|
||||
@@ -186,7 +207,9 @@ def build_pump_valve_maps(G, pump_backbone):
|
||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||
|
||||
for node in filtered_backbone:
|
||||
if is_integrated_pump(G.nodes[node]["class"]):
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
if is_integrated_pump(node_class, node):
|
||||
pumps_from_node[node] = node
|
||||
valve_from_node[node] = node
|
||||
debug_print(f" - 集成泵-阀: {node}")
|
||||
|
||||
@@ -16,11 +16,14 @@ class BasicConfig:
|
||||
upload_registry = False
|
||||
machine_name = "undefined"
|
||||
vis_2d_enable = False
|
||||
no_update_feedback = False
|
||||
enable_resource_load = True
|
||||
communication_protocol = "websocket"
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@@ -143,5 +146,5 @@ def load_config(config_path=None):
|
||||
traceback.print_exc()
|
||||
exit(1)
|
||||
else:
|
||||
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
|
||||
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
|
||||
load_config(config_path)
|
||||
|
||||
@@ -6,7 +6,7 @@ Coin Cell Assembly Workstation
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo
|
||||
from unilabos.device_comms.workstation_communication import (
|
||||
WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication
|
||||
@@ -61,7 +61,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
# 创建资源跟踪器(如果没有提供)
|
||||
if resource_tracker is None:
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker
|
||||
resource_tracker = DeviceNodeResourceTracker()
|
||||
|
||||
# 初始化基类
|
||||
|
||||
@@ -43,7 +43,7 @@ class Base(ABC):
|
||||
self._type = typ
|
||||
self._data_type = data_type
|
||||
self._node: Optional[Node] = None
|
||||
|
||||
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
@@ -66,7 +66,7 @@ class Base(ABC):
|
||||
# 直接以字符串形式处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||
# 提取括号内的内容
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
@@ -116,16 +116,16 @@ class Base(ABC):
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""读取节点值,返回(值, 是否出错)"""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def write(self, value: Any) -> bool:
|
||||
"""写入节点值,返回是否出错"""
|
||||
pass
|
||||
|
||||
|
||||
@property
|
||||
def type(self) -> NodeType:
|
||||
return self._type
|
||||
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
return self._node_id
|
||||
@@ -210,15 +210,15 @@ class Method(Base):
|
||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||
self._parent_node_id = parent_node_id
|
||||
self._parent_node = None
|
||||
|
||||
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||
import re
|
||||
|
||||
|
||||
nid = self._parent_node_id
|
||||
|
||||
|
||||
# 如果已经是 NodeId 对象,直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
@@ -227,16 +227,16 @@ class Method(Base):
|
||||
return self._parent_node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 字符串处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
|
||||
# 常见短格式
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
@@ -271,7 +271,7 @@ class Method(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""方法节点不支持写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def call(self, *args) -> Tuple[Any, bool]:
|
||||
"""调用方法,返回(返回值, 是否出错)"""
|
||||
try:
|
||||
@@ -285,7 +285,7 @@ class Method(Base):
|
||||
class Object(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str):
|
||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""对象节点不支持直接读取操作"""
|
||||
return None, True
|
||||
@@ -293,7 +293,7 @@ class Object(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""对象节点不支持直接写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def get_children(self) -> Tuple[List[Node], bool]:
|
||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||
try:
|
||||
@@ -301,4 +301,4 @@ class Object(Base):
|
||||
return children, False
|
||||
except Exception as e:
|
||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||
return [], True
|
||||
return [], True
|
||||
|
||||
@@ -128,14 +128,21 @@ class ResourceVisualization:
|
||||
new_dev.set("device_name", node["id"]+"_")
|
||||
# if node["parent"] is not None:
|
||||
# new_dev.set("station_name", node["parent"]+'_')
|
||||
|
||||
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
||||
if "position" in node:
|
||||
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
||||
if "rotation" in node["config"]:
|
||||
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
|
||||
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
|
||||
if "pose" in node:
|
||||
new_dev.set("x",str(float(node["pose"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["pose"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["pose"]["position"]["z"])/1000))
|
||||
new_dev.set("rx",str(float(node["pose"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["pose"]["rotation"]["y"])))
|
||||
new_dev.set("r",str(float(node["pose"]["rotation"]["z"])))
|
||||
if "device_config" in node["config"]:
|
||||
for key, value in node["config"]["device_config"].items():
|
||||
new_dev.set(key, str(value))
|
||||
|
||||
73
unilabos/devices/LICENSE
Normal file
@@ -0,0 +1,73 @@
|
||||
Uni-Lab-OS软件许可使用准则
|
||||
|
||||
|
||||
本软件使用准则(以下简称"本准则")旨在规范用户在使用Uni-Lab-OS软件(以下简称"本软件")过程中的行为和义务。在下载、安装、使用或以任何方式访问本软件之前,请务必仔细阅读并理解以下条款和条件。若您不同意本准则的全部或部分内容,请您立即停止使用本软件。一旦您开始访问、下载、安装、使用本软件,即表示您已阅读、理解并同意接受本准则的约束。
|
||||
|
||||
1、使用许可
|
||||
1.1 本软件的所有权及版权归北京深势科技有限公司(以下简称"深势科技")所有。在遵守本准则的前提下,深势科技特此授予学术用户(以下简称"您")一个全球范围内的、非排他性的、免版权费用的使用许可,可为了满足学术目的而使用本软件。
|
||||
|
||||
1.2 本准则下授予的许可仅适用于本软件的二进制代码版本。您不对本软件源代码拥有任何权利。
|
||||
|
||||
2、使用限制
|
||||
2.1 本准则仅授予学术用户出于学术目的使用本软件,任何商业组织、商业机构或其他非学术用户不得使用本软件,如果违反本条款,深势科技将保留一切追诉的权利。
|
||||
2.2 您将本软件用于任何商业行为,应取得深势科技的商业许可。
|
||||
2.3 您不得将本软件或任何形式的衍生作品用于任何商业目的,也不得将其出售、出租、转让、分发或以其他方式提供给任何第三方。您必须确保本软件的使用仅限于您个人学术研究,禁止您为任何其他实体的利益使用本软件(无论是否收费)。
|
||||
2.4 您不得以任何方式修改、破解、反编译、反汇编、反向工程、隔离、分离或以其他方式从任何程序或文档中提取源代码或试图发现本软件的源代码。您不得以任何方式去除、修改或屏蔽本软件中的任何版权、商标或其他专有权利声明。您不得使用本软件进行任何非法活动,包括但不限于侵犯他人的知识产权、隐私权等。
|
||||
2.5 您同意将本软件仅用于合法的学术目的,且遵守您所在国家或地区的法律法规,您将承担因违反法律法规而产生的一切法律责任。
|
||||
|
||||
3、软件所有权
|
||||
本软件在此仅作使用许可,并非出售。本软件及与软件有关的全部文档的所有权及其他所有权利(包括但不限于知识产权和商业秘密),始终是深势科技的专有财产,您不拥有任何权利,但本准则下被明确授予的有限的使用许可权利除外。
|
||||
|
||||
4、衍生作品传播规范
|
||||
若您传播基于Uni-Lab-OS程序修改形成的作品,须同时满足以下全部条件:
|
||||
4.1 作品必须包含显著声明,明确标注修改内容及修改日期;
|
||||
4.2 作品必须声明本作品依据本许可协议发布;
|
||||
4.3 必须将整个作品(包括修改部分)作为整体授予获取副本者本许可协议的保障,且该许可将自动延伸适用于作品全组件(无论其以何种形式打包);
|
||||
4.4 若衍生作品含交互式用户界面:每个界面均须显示合规法律声明,若原始Uni-Lab-OS程序的交互界面未展示法律声明,您的衍生作品可免除此义务。
|
||||
|
||||
5、提出建议
|
||||
您可以对本软件提出建议,前提是:
|
||||
(i)您声明并保证,该建议未侵害任何第三方的任何知识产权;
|
||||
(ii)您承认,深势科技有权使用该建议,但无使用该建议的义务;
|
||||
(iii)您授予深势科技一项非独占的、不可撤销的、可分许可的、无版权费的、全球范围的著作权许可,以复制、分发、传播、公开展示、公开表演、修改、翻译、基于其制作衍生作品、生产、制作、推销、销售、提供销售和/或以其他方式整体或部分地使用该建议和基于其的衍生作品,包括但不限于,通过将该建议整体或部分地纳入深势科技的软件和/或其他软件,以及在现存的或将来任何时候存在的任何媒介中或通过该媒介体现,以及为从事上述活动而授予多个分许可;
|
||||
(iv)您特此授予深势科技一项永久的、全球范围的、非独占性的、免费的、免特许权使用费的、不可撤销的专利许可,许可其制造、委托制造、使用、要约销售、销售、进口及以其他方式转让该建议和基于其的衍生专利。上述专利许可的适用范围仅限于以下专利权利要求:您有权许可的、且仅因您的建议本身,或因您的建议与所提交的本软件结合而必然构成侵权的专利权利要求。若任何实体针对您或其他实体提起专利诉讼(包括诉讼中的交叉诉讼或反诉),主张该建议或您所贡献的软件构成直接或间接专利侵权,则依据本协议授予的、针对该建议或软件的任何专利许可,自该诉讼提起之日起终止。
|
||||
(v)您放弃对该建议的任何权利或主张,深势科技无需承担任何义务、版税或基于知识产权或其他方面的限制。
|
||||
|
||||
6、引用要求
|
||||
如您使用本软件获得的成果发表在出版物上,您应在成果中承认对Uni-Lab-OS软件的使用并标注权利人名称。引用 Uni-Lab-OS时请使用以下内容:
|
||||
@article{gao2025unilabos,
|
||||
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
|
||||
doi = {10.48550/arXiv.2512.21766},
|
||||
publisher = {arXiv},
|
||||
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
|
||||
year = {2025}
|
||||
}
|
||||
|
||||
7、保留权利
|
||||
您认可,所有未被明确授予您的本软件的权利,无论是当前或今后存在的,均由深势科技予以保留,任何未经深势科技明确授权而使用本软件的行为将被视为侵权,深势科技有权追究侵权者的一切法律责任。
|
||||
|
||||
8、保密信息
|
||||
您同意将本软件代码及相关文档视为深势科技的机密信息,您不会向任何第三方提供相关代码,并将采取合理审慎的使用态度来防止本软件代码及相关文档被泄露。
|
||||
|
||||
9、无保证
|
||||
该软件是"按原样"提供的,没有任何明示或暗示的保证,不包含任何代码或规范没有缺陷、适销性、适用于特定目的或不侵犯第三方权利的保证。您同意您自主承担使用本软件或与本准则有关的全部风险。
|
||||
|
||||
10、免责条款
|
||||
在任何情况下,无论基于侵权(包括过失)、合同或其他法律理论,除非适用法律强制规定(如故意或重大过失行为)或另有书面协议,深势科技不对被许可人因软件许可、使用或无法使用软件所致损害承担责任(包括任何性质的直接、间接、特殊、偶发或后果性损害,例如但不限于商誉损失、停工损失、计算机故障或失灵造成的损害,以及其他一切商业损害或损失),即使深势科技已被告知发生此类损害的可能性亦不例外。
|
||||
被许可人在再分发软件或其衍生作品时,仅能以自身名义独立承担责任进行操作,不得代表深势科技或其他被许可人。
|
||||
|
||||
11、终止
|
||||
如果您以任何方式违反本准则或未能遵守本准则的任何重要条款或条件,则您被授予的所有权利将自动终止。
|
||||
|
||||
12、举报
|
||||
如果您认为有人违反了本准则,请向深势科技进行举报,深势科技将对您的身份进行严格保密,举报邮箱changjh@dp.tech。
|
||||
|
||||
13、法律管辖
|
||||
本准则中的任何内容均不得解释为通过暗示、禁止反悔或其他方式授予本准则中授予的许可或权利以外的任何许可或权利。如果本准则的任何条款被认定为不可执行,则仅在必要的范围内对该条款进行修改,使其可执行。本准则应受中华人民共和国法律管辖,不适用法律冲突条款及《联合国国际货物销售合同公约》,因本准则产生的一切争议由北京市海淀区人民法院管辖。
|
||||
|
||||
14、未来版本
|
||||
深势科技保留不经事先通知随时变更或停止本软件或本准则的权利。
|
||||
|
||||
15、语言优先
|
||||
本准则同时具有中文版本和英文版本,如果英文版本和中文版本有冲突,以中文版本为准。
|
||||
|
||||
73
unilabos/devices/LICENSE_eng
Normal file
@@ -0,0 +1,73 @@
|
||||
Uni-Lab-OS License Agreement
|
||||
|
||||
Preamble
|
||||
This License Agreement (the "Agreement") is instituted to govern user conduct and obligations in relation to the utilization of the Uni-Lab-OS (the "Software"). By accessing, downloading, installing, or utilizing the Software in any manner, you hereby acknowledge that you have meticulously reviewed, comprehended, and consented to be legally bound by the terms herein. If you dissent from any provision of this Agreement, you must forthwith cease all interaction with the Software.
|
||||
|
||||
1. Grant of License
|
||||
1.1 The proprietary rights to the Software are exclusively retained by Beijing DP Technology Co., Ltd. ("DP Technology"). Subject to full compliance with this Agreement, DP Technology hereby grants academic users ("Licensee") a worldwide, non-exclusive, royalty-free license to untilise the Software solely for non-commercial academic pursuits.
|
||||
|
||||
1.2 The foregoing license applies exclusively to the Software's executable binary code. No rights whatsoever are conferred to the Software's source code.
|
||||
|
||||
2. Usage Restrictions
|
||||
2.1 This license is restricted to academic users engaging in scholastic activities. Commercial entities, institutions, or any non-academic parties are expressly prohibited from utilizing the Software. Violations of this clause shall entitle DP Technology to pursue all available legal remedies.
|
||||
2.2 The Licensee shall obtain a commercial license from DP Technology for any commercial use of the Software.
|
||||
2.3 The Licensee shall not utilise the Software or any derivative works for commercial purposes, nor distribute, sublicense, lease, transfer, or otherwise disseminate the Software to third parties. The Licensee is strictly prohibited from utilizing the Software for the benefit of any third-party entity, whether gratuitously or otherwise.
|
||||
2.4 Reverse engineering, decompilation, disassembly, code isolation, or any attempt to derive source code from the Software is strictly prohibited. The Licensee shall not alter, circumvent, or remove copyright notices, trademarks, or proprietary legends embedded in the Software. Use of the Software for unlawful activities—including but not limited to intellectual property infringement or privacy violations—is categorically barred.
|
||||
2.5 The Licensee warrants that the Software shall be utilised solely for lawful academic purposes in compliance with applicable jurisdictional statutes. All legal liabilities arising from noncompliance shall be borne exclusively by the Licensee.
|
||||
|
||||
3. Proprietary Rights
|
||||
This Agreement confers a license to utilise the Software, not a transfer of ownership. All intellectual property rights—including copyrights, patents, trade secrets, and documentation—remain the exclusive dominion of DP Technology. The Licensee acquires no entitlements beyond the limited usage privileges expressly delineated herein.
|
||||
|
||||
4. Derivative Work
|
||||
You may convey a work based on the Software, or the modifications to produce it from the Software, provided that you meet all of these conditions:
|
||||
4.1 The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
4.2 The work must carry prominent notices stating that it is released under this License.
|
||||
4.3 You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
4.4 If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Software has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
5. Feedback and Proposals
|
||||
Licensees may submit proposals, suggestions, or improvements pertaining to the Software ("Feedback") under the following conditions:
|
||||
(a) Licensee represents and warrants that such Feedback does not infringe upon any third-party intellectual property rights;
|
||||
(b) Licensee acknowledges that DP Technology reserves the right, but assumes no obligation, to utilize such Feedback;
|
||||
(c) Licensee irrevocably grants DP Technology a non-exclusive, royalty-free, perpetual, worldwide, sublicensable copyright license to reproduce, distribute, modify, publicly perform or display, translate, create derivative works of, commercialize, and otherwise exploit the Feedback in any medium or format, whether now known or hereafter devised, including the right to grant multiple tiers of sublicenses to enable such activities;
|
||||
(d) Licensee hereby grants DP Technology a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Feedback and such Derivative Works, where such license applies only to those patent claimss licensable by Licensee that are necessarily infringed by the Feedback(s) alone or by comibination of the Feedback(s) with the Software to which such Feedback(s) were submitted. If any entity institutes patent litigation against Licensee or any other entity (including a cross-claim orcounterclaim in a lawsuit) alleging that the Feedback, or the Software to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted under this Agreement for the Feedback or Software shall terminate as of the date such litigation is filed.
|
||||
(e) Licensee hereby waives all claims, proprietary rights, or restrictions related to DP Technology's use of such Feedback.
|
||||
|
||||
6. Citation Requirement
|
||||
If academic or research output generated using the Software is published, Licensee must explicitly acknowledge the use of Uni-Lab-OS and attribute ownership to DP Technology. The following citation must be included:
|
||||
@article{gao2025unilabos,
|
||||
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
|
||||
doi = {10.48550/arXiv.2512.21766},
|
||||
publisher = {arXiv},
|
||||
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
|
||||
year = {2025}
|
||||
}
|
||||
|
||||
7. Reservation of Rights
|
||||
All rights not expressly granted herein, whether existing now or arising in the future, are exclusively reserved by DP Technology. Any unauthorized use of the Software beyond the scope of this Agreement constitutes infringement, and DP Technology reserves all legal rights to pursue remedies against violators.
|
||||
|
||||
8. Confidentiality
|
||||
Licensee agrees to treat the Software's code, documentation, and related materials as confidential information. Licensee shall not disclose such materials to third parties and shall employ reasonable safeguards to prevent unauthorized access, dissemination, or misuse.
|
||||
|
||||
9. Disclaimer of Warranties
|
||||
The software is provided "as is," without warranties of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, or error-free operation. Licensee accepts all risks associated with the use of the software.
|
||||
|
||||
10. Limitation of Liability
|
||||
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall DP Technology be liable to Licensee for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the software (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if DP Technology has been advised of the possibility of such damages.
|
||||
While redistributing the Software or Derivative Works thereof, Licensee may act only on Licensee's own behalf and on Licensee's sole responsibility, not on behalf of DP Technology or any other Licensee.
|
||||
|
||||
11. Termination
|
||||
All rights granted herein shall terminate immediately and automatically if Licensee materially breaches any provision of this Agreement.
|
||||
|
||||
12. Reporting Violations
|
||||
To report suspected violations of this Agreement, notify DP Technology via the designated email address: changjh@dp.tech. DP Technology shall maintain the confidentiality of the reporter's identity.
|
||||
|
||||
13. Governing Law and Dispute Resolution
|
||||
This Agreement shall be governed by the laws of the People's Republic of China, excluding its conflict of laws principles and the United Nations Convention on Contracts for the International Sale of Goods. Any dispute arising from this Agreement shall be exclusively adjudicated by the Haidian District People's Court in Beijing.
|
||||
|
||||
14. Amendments and Updates
|
||||
DP Technology reserves the right to modify, suspend, or terminate the Software or this Agreement at any time without prior notice.
|
||||
|
||||
15. Language Priority
|
||||
This Agreement is provided in both Chinese and English. In the event of any discrepancy, the Chinese version shall prevail.
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 液体处理工作站集成模块
|
||||
|
||||
该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括:
|
||||
- 硬件后端和抽象接口
|
||||
- 资源定义和管理
|
||||
- 协议执行和液体传输
|
||||
- 工作台配置和布局
|
||||
|
||||
主要组件:
|
||||
- LaiYuLiquidBackend: 硬件后端实现
|
||||
- LaiYuLiquid: 液体处理器抽象接口
|
||||
- 各种资源类:枪头架、板、容器等
|
||||
- 便捷创建函数和配置管理
|
||||
|
||||
使用示例:
|
||||
from unilabos.devices.laiyu_liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidBackend,
|
||||
create_standard_deck,
|
||||
create_tip_rack_1000ul
|
||||
)
|
||||
|
||||
# 创建后端和液体处理器
|
||||
backend = LaiYuLiquidBackend()
|
||||
lh = LaiYuLiquid(backend=backend)
|
||||
|
||||
# 创建工作台
|
||||
deck = create_standard_deck()
|
||||
lh.deck = deck
|
||||
|
||||
# 设置和运行
|
||||
await lh.setup()
|
||||
"""
|
||||
|
||||
# 版本信息
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Integration Team"
|
||||
__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块"
|
||||
|
||||
# 驱动程序导入
|
||||
from .drivers import (
|
||||
XYZStepperController,
|
||||
SOPAPipette,
|
||||
MotorAxis,
|
||||
MotorStatus,
|
||||
SOPAConfig,
|
||||
SOPAStatusCode,
|
||||
StepperMotorDriver
|
||||
)
|
||||
|
||||
# 控制器导入
|
||||
from .controllers import (
|
||||
XYZController,
|
||||
PipetteController,
|
||||
)
|
||||
|
||||
# 后端导入
|
||||
from .backend.rviz_backend import (
|
||||
LiquidHandlerRvizBackend,
|
||||
)
|
||||
|
||||
# 资源类和创建函数导入
|
||||
from .core.laiyu_liquid_res import (
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack
|
||||
)
|
||||
|
||||
# 主设备类和配置
|
||||
from .core.laiyu_liquid_main import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack,
|
||||
create_quick_setup
|
||||
)
|
||||
|
||||
# 后端创建函数导入
|
||||
from .backend import (
|
||||
LaiYuLiquidBackend,
|
||||
create_laiyu_backend,
|
||||
)
|
||||
|
||||
# 导出所有公共接口
|
||||
__all__ = [
|
||||
# 版本信息
|
||||
"__version__",
|
||||
"__author__",
|
||||
"__description__",
|
||||
|
||||
# 驱动程序
|
||||
"SOPAPipette",
|
||||
"SOPAConfig",
|
||||
"StepperMotorDriver",
|
||||
"XYZStepperController",
|
||||
|
||||
# 控制器
|
||||
"PipetteController",
|
||||
"XYZController",
|
||||
|
||||
# 后端
|
||||
"LiquidHandlerRvizBackend",
|
||||
|
||||
# 资源创建函数
|
||||
"create_tip_rack_1000ul",
|
||||
"create_tip_rack_200ul",
|
||||
"create_96_well_plate",
|
||||
"create_deep_well_plate",
|
||||
"create_8_tube_rack",
|
||||
"create_standard_deck",
|
||||
"create_waste_container",
|
||||
"create_wash_container",
|
||||
"create_reagent_container",
|
||||
"load_deck_config",
|
||||
|
||||
# 后端创建函数
|
||||
"create_laiyu_backend",
|
||||
|
||||
# 主要类
|
||||
"LaiYuLiquid",
|
||||
"LaiYuLiquidConfig",
|
||||
"LaiYuLiquidBackend",
|
||||
"LaiYuLiquidDeck",
|
||||
|
||||
# 工具函数
|
||||
"get_version",
|
||||
"get_supported_resources",
|
||||
"create_quick_setup",
|
||||
"validate_installation",
|
||||
"print_module_info",
|
||||
"setup_logging",
|
||||
]
|
||||
|
||||
# 别名定义,为了向后兼容
|
||||
LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名
|
||||
LaiYuLiquidController = XYZController # 控制器别名
|
||||
LaiYuLiquidDriver = XYZStepperController # 驱动器别名
|
||||
|
||||
# 模块级别的便捷函数
|
||||
|
||||
def get_version() -> str:
|
||||
"""
|
||||
获取模块版本
|
||||
|
||||
Returns:
|
||||
str: 版本号
|
||||
"""
|
||||
return __version__
|
||||
|
||||
|
||||
def get_supported_resources() -> dict:
|
||||
"""
|
||||
获取支持的资源类型
|
||||
|
||||
Returns:
|
||||
dict: 支持的资源类型字典
|
||||
"""
|
||||
return {
|
||||
"tip_racks": {
|
||||
"LaiYuLiquidTipRack": LaiYuLiquidTipRack,
|
||||
},
|
||||
"containers": {
|
||||
"LaiYuLiquidContainer": LaiYuLiquidContainer,
|
||||
},
|
||||
"decks": {
|
||||
"LaiYuLiquidDeck": LaiYuLiquidDeck,
|
||||
},
|
||||
"devices": {
|
||||
"LaiYuLiquid": LaiYuLiquid,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_quick_setup() -> tuple:
|
||||
"""
|
||||
快速创建基本设置
|
||||
|
||||
Returns:
|
||||
tuple: (backend, controllers, resources) 的元组
|
||||
"""
|
||||
# 创建后端
|
||||
backend = LiquidHandlerRvizBackend()
|
||||
|
||||
# 创建控制器(使用默认端口进行演示)
|
||||
pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4)
|
||||
xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False)
|
||||
|
||||
# 创建测试资源
|
||||
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||
well_plate = create_96_well_plate("96_well_plate")
|
||||
|
||||
controllers = {
|
||||
'pipette': pipette_controller,
|
||||
'xyz': xyz_controller
|
||||
}
|
||||
|
||||
resources = {
|
||||
'tip_rack_1000': tip_rack_1000,
|
||||
'tip_rack_200': tip_rack_200,
|
||||
'well_plate': well_plate
|
||||
}
|
||||
|
||||
return backend, controllers, resources
|
||||
|
||||
|
||||
def validate_installation() -> bool:
|
||||
"""
|
||||
验证模块安装是否正确
|
||||
|
||||
Returns:
|
||||
bool: 安装是否正确
|
||||
"""
|
||||
try:
|
||||
# 检查核心类是否可以导入
|
||||
from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig
|
||||
from .backend import LaiYuLiquidBackend
|
||||
from .controllers import XYZController, PipetteController
|
||||
from .drivers import XYZStepperController, SOPAPipette
|
||||
|
||||
# 尝试创建基本对象
|
||||
config = LaiYuLiquidConfig()
|
||||
backend = create_laiyu_backend("validation_test")
|
||||
|
||||
print("模块安装验证成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"模块安装验证失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def print_module_info():
|
||||
"""打印模块信息"""
|
||||
print(f"LaiYu_Liquid 集成模块")
|
||||
print(f"版本: {__version__}")
|
||||
print(f"作者: {__author__}")
|
||||
print(f"描述: {__description__}")
|
||||
print(f"")
|
||||
print(f"支持的资源类型:")
|
||||
|
||||
resources = get_supported_resources()
|
||||
for category, types in resources.items():
|
||||
print(f" {category}:")
|
||||
for type_name, type_class in types.items():
|
||||
print(f" - {type_name}: {type_class.__name__}")
|
||||
|
||||
print(f"")
|
||||
print(f"主要功能:")
|
||||
print(f" - 硬件集成: LaiYuLiquidBackend")
|
||||
print(f" - 抽象接口: LaiYuLiquid")
|
||||
print(f" - 资源管理: 各种资源类和创建函数")
|
||||
print(f" - 协议执行: transfer_liquid 和相关函数")
|
||||
print(f" - 配置管理: deck.json 和加载函数")
|
||||
|
||||
|
||||
# 模块初始化时的检查
|
||||
def _check_dependencies():
|
||||
"""检查依赖项"""
|
||||
try:
|
||||
import pylabrobot
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
return True
|
||||
except ImportError as e:
|
||||
import logging
|
||||
logging.warning(f"缺少依赖项 {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 执行依赖检查
|
||||
_dependencies_ok = _check_dependencies()
|
||||
|
||||
if not _dependencies_ok:
|
||||
import logging
|
||||
logging.warning("某些依赖项缺失,模块功能可能受限")
|
||||
|
||||
|
||||
# 模块级别的日志配置
|
||||
import logging
|
||||
|
||||
def setup_logging(level: str = "INFO"):
|
||||
"""
|
||||
设置模块日志
|
||||
|
||||
Args:
|
||||
level: 日志级别 (DEBUG, INFO, WARNING, ERROR)
|
||||
"""
|
||||
logger = logging.getLogger("LaiYu_Liquid")
|
||||
logger.setLevel(getattr(logging, level.upper()))
|
||||
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 默认日志设置
|
||||
_logger = setup_logging()
|
||||
@@ -1,9 +0,0 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端模块
|
||||
|
||||
提供设备后端接口和实现
|
||||
"""
|
||||
|
||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||
|
||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||
@@ -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)
|
||||