mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 21:19:19 +00:00
Compare commits
94 Commits
dev
...
ead43b2bc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ead43b2bc1 | ||
|
|
cef86fd98d | ||
|
|
6993e97ae9 | ||
|
|
db396bcab3 | ||
|
|
1fed8de57d | ||
|
|
63eb0c0a4c | ||
|
|
888c6cf542 | ||
|
|
cc248fc32c | ||
|
|
cfe64b023b | ||
|
|
ad1312cf26 | ||
|
|
799813f85b | ||
|
|
19c9d655d0 | ||
|
|
f9a9e35269 | ||
|
|
8cd306cd32 | ||
|
|
816a0d747b | ||
|
|
b0cff1a7a8 | ||
|
|
71d57c5631 | ||
|
|
546fb633ec | ||
|
|
a3c7fa9385 | ||
|
|
c6cf84def0 | ||
|
|
86512a0482 | ||
|
|
3ddbc1c9b7 | ||
|
|
abf1005241 | ||
|
|
c475eabb60 | ||
|
|
3ad20c85a5 | ||
|
|
44fc80c70f | ||
|
|
8ba911bb55 | ||
|
|
896f287d92 | ||
|
|
0d150f7acd | ||
|
|
c27f7e42d6 | ||
|
|
cc56a68bc6 | ||
|
|
d7302c3b35 | ||
|
|
b46a51c40e | ||
|
|
c6780087b8 | ||
|
|
1ef698dde6 | ||
|
|
91aadba4ef | ||
|
|
b1cdef9185 | ||
|
|
9854ed8c9c | ||
|
|
52544a2c69 | ||
|
|
5ce433e235 | ||
|
|
c7c14d2332 | ||
|
|
6fdd482649 | ||
|
|
d390236318 | ||
|
|
ed8ee29732 | ||
|
|
ffc583e9d5 | ||
|
|
f1ad0c9c96 | ||
|
|
8fa3407649 | ||
|
|
d3282822fc | ||
|
|
554bcade24 | ||
|
|
a662c75de1 | ||
|
|
931614fe64 | ||
|
|
d39662f65f | ||
|
|
acf5fdebf8 | ||
|
|
7f7b1c13c0 | ||
|
|
75f09034ff | ||
|
|
549a50220b | ||
|
|
4189a2cfbe | ||
|
|
48895a9bb1 | ||
|
|
891f126ed6 | ||
|
|
4d3475a849 | ||
|
|
b475db66df | ||
|
|
a625a86e3e | ||
|
|
37e0f1037c | ||
|
|
a242253145 | ||
|
|
448e0074b7 | ||
|
|
304827fc8d | ||
|
|
872b3d781f | ||
|
|
813400f2b4 | ||
|
|
b6dfe2b944 | ||
|
|
8807865649 | ||
|
|
5fc7eb7586 | ||
|
|
9bd72b48e1 | ||
|
|
42b78ab4c1 | ||
|
|
9645609a05 | ||
|
|
a2a827d7ac | ||
|
|
bb3ca645a4 | ||
|
|
37ee43d19a | ||
|
|
bc30f23e34 | ||
|
|
166d84afe1 | ||
|
|
1b43c53015 | ||
|
|
d4415f5a35 | ||
|
|
0260cbbedb | ||
|
|
7c440d10ab | ||
|
|
c85c49817d | ||
|
|
c70eafa5f0 | ||
|
|
b64466d443 | ||
|
|
ef3f24ed48 | ||
|
|
2a8e8d014b | ||
|
|
e0da1c7217 | ||
|
|
51d3e61723 | ||
|
|
6b5765bbf3 | ||
|
|
eb1f3fbe1c | ||
|
|
fb93b1cd94 | ||
|
|
9aeffebde1 |
@@ -1,62 +0,0 @@
|
|||||||
# unilabos: Production package (depends on unilabos-env + pip unilabos)
|
|
||||||
# For production deployment
|
|
||||||
|
|
||||||
package:
|
|
||||||
name: unilabos
|
|
||||||
version: 0.10.19
|
|
||||||
|
|
||||||
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.19
|
|
||||||
|
|
||||||
about:
|
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
|
||||||
license: GPL-3.0-only
|
|
||||||
description: "UniLabOS - Production package with minimal ROS2 dependencies"
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# unilabos-env: conda environment dependencies (ROS2 + conda packages)
|
|
||||||
|
|
||||||
package:
|
|
||||||
name: unilabos-env
|
|
||||||
version: 0.10.19
|
|
||||||
|
|
||||||
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"
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# unilabos-full: Full package with all features
|
|
||||||
# Depends on unilabos + complete ROS2 desktop + dev tools
|
|
||||||
|
|
||||||
package:
|
|
||||||
name: unilabos-full
|
|
||||||
version: 0.10.19
|
|
||||||
|
|
||||||
build:
|
|
||||||
noarch: generic
|
|
||||||
|
|
||||||
requirements:
|
|
||||||
run:
|
|
||||||
# Base unilabos package (includes unilabos-env)
|
|
||||||
- uni-lab::unilabos ==0.10.19
|
|
||||||
# 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"
|
|
||||||
92
.conda/recipe.yaml
Normal file
92
.conda/recipe.yaml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package:
|
||||||
|
name: unilabos
|
||||||
|
version: 0.10.13
|
||||||
|
|
||||||
|
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"
|
||||||
9
.conda/scripts/post-link.bat
Normal file
9
.conda/scripts/post-link.bat
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@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
|
||||||
9
.conda/scripts/post-link.sh
Normal file
9
.conda/scripts/post-link.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/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
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-device
|
|
||||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 添加新设备到 Uni-Lab-OS
|
|
||||||
|
|
||||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
|
|
||||||
|
|
||||||
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 装饰器参考
|
|
||||||
|
|
||||||
### @device — 设备类装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device
|
|
||||||
|
|
||||||
# 单设备
|
|
||||||
@device(
|
|
||||||
id="my_device.vendor", # 注册表唯一标识(必填)
|
|
||||||
category=["temperature"], # 分类标签列表(必填)
|
|
||||||
description="设备描述", # 设备描述
|
|
||||||
display_name="显示名称", # UI 显示名称(默认用 id)
|
|
||||||
icon="DeviceIcon.webp", # 图标文件名
|
|
||||||
version="1.0.0", # 版本号
|
|
||||||
device_type="python", # "python" 或 "ros2"
|
|
||||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
|
||||||
model={...}, # 3D 模型配置
|
|
||||||
hardware_interface=HardwareInterface(...), # 硬件通信接口
|
|
||||||
)
|
|
||||||
|
|
||||||
# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置)
|
|
||||||
@device(
|
|
||||||
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
|
|
||||||
id_meta={
|
|
||||||
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
|
|
||||||
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
|
|
||||||
},
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### @action — 动作方法装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import action
|
|
||||||
|
|
||||||
@action # 无参:注册为 UniLabJsonCommand 动作
|
|
||||||
@action() # 同上
|
|
||||||
@action(description="执行操作") # 带描述
|
|
||||||
@action(
|
|
||||||
action_type=HeatChill, # 指定 ROS Action 消息类型
|
|
||||||
goal={"temperature": "temp"}, # Goal 字段映射
|
|
||||||
feedback={}, # Feedback 字段映射
|
|
||||||
result={}, # Result 字段映射
|
|
||||||
handles=[...], # 动作级别端口
|
|
||||||
goal_default={"temp": 25.0}, # Goal 默认值
|
|
||||||
placeholder_keys={...}, # 参数占位符
|
|
||||||
always_free=True, # 不受排队限制
|
|
||||||
auto_prefix=True, # 强制使用 auto- 前缀
|
|
||||||
parent=True, # 从父类 MRO 获取参数签名
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**自动识别规则:**
|
|
||||||
- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名)
|
|
||||||
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
|
|
||||||
- `_` 开头的方法 → 不扫描
|
|
||||||
- `@not_action` 标记的方法 → 排除
|
|
||||||
|
|
||||||
### @topic_config — 状态属性配置
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config(
|
|
||||||
period=5.0, # 发布周期(秒),默认 5.0
|
|
||||||
print_publish=False, # 是否打印发布日志
|
|
||||||
qos=10, # QoS 深度,默认 10
|
|
||||||
name="custom_name", # 自定义发布名称(默认用属性名)
|
|
||||||
)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self.data.get("temperature", 0.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 辅助装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import not_action, always_free
|
|
||||||
|
|
||||||
@not_action # 标记为非动作(post_init、辅助方法等)
|
|
||||||
@always_free # 标记为不受排队限制(查询类操作)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 设备模板
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
|
||||||
from unilabos.registry.decorators import device, action, topic_config, not_action
|
|
||||||
|
|
||||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
|
||||||
class MyDevice:
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
|
||||||
self.device_id = device_id or "my_device"
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
|
||||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
|
|
||||||
self._ros_node = ros_node
|
|
||||||
|
|
||||||
@action
|
|
||||||
async def initialize(self) -> bool:
|
|
||||||
self.data["status"] = "Ready"
|
|
||||||
return True
|
|
||||||
|
|
||||||
@action
|
|
||||||
async def cleanup(self) -> bool:
|
|
||||||
self.data["status"] = "Offline"
|
|
||||||
return True
|
|
||||||
|
|
||||||
@action(description="执行操作")
|
|
||||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
|
||||||
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
"""无 @action → 自动注册为 'auto-get_info' 动作"""
|
|
||||||
return {"device_id": self.device_id}
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
|
||||||
return self.data.get("status", "Idle")
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config(period=2.0)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self.data.get("temperature", 0.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 要点
|
|
||||||
|
|
||||||
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
|
|
||||||
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
|
|
||||||
- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
|
|
||||||
- 运行时状态存储在 `self.data` 字典中
|
|
||||||
- 设备文件放在 `unilabos/devices/<category>/` 目录下
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-resource
|
|
||||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. 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 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 资源类型
|
|
||||||
|
|
||||||
| 类型 | 基类 | 用途 | 示例 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
|
||||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
|
||||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
|
||||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
|
||||||
|
|
||||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
|
||||||
|
|
||||||
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## @resource 装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
@resource(
|
|
||||||
id="my_resource_id", # 注册表唯一标识(必填)
|
|
||||||
category=["bottles"], # 分类标签列表(必填)
|
|
||||||
description="资源描述",
|
|
||||||
icon="", # 图标
|
|
||||||
version="1.0.0",
|
|
||||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
|
||||||
model={...}, # 3D 模型配置
|
|
||||||
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 创建规范
|
|
||||||
|
|
||||||
### 命名规则
|
|
||||||
|
|
||||||
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
|
|
||||||
2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
|
||||||
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
|
|
||||||
|
|
||||||
### 子物料命名示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Carrier 内部的 sites 用 name 前缀
|
|
||||||
for k, v in sites.items():
|
|
||||||
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
|
|
||||||
|
|
||||||
# Carrier 中放置 Bottle 时用 name 前缀
|
|
||||||
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
|
|
||||||
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
|
|
||||||
|
|
||||||
# create_homogeneous_resources 使用 name_prefix
|
|
||||||
sites=create_homogeneous_resources(
|
|
||||||
klass=ResourceHolder,
|
|
||||||
locations=[...],
|
|
||||||
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
|
|
||||||
)
|
|
||||||
|
|
||||||
# Deck setup 中用仓库名称作为 name 传入
|
|
||||||
self.warehouses = {
|
|
||||||
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
|
|
||||||
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 其他规范
|
|
||||||
|
|
||||||
- **max_volume 单位为 μL**:500mL = 500000
|
|
||||||
- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
|
|
||||||
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
|
|
||||||
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
|
|
||||||
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/` 下
|
|
||||||
- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
|
|
||||||
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 资源模板
|
|
||||||
|
|
||||||
### Bottle
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
from unilabos.resources.itemized_carrier import Bottle
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
|
|
||||||
def My_Reagent_Bottle(
|
|
||||||
name: str,
|
|
||||||
diameter: float = 70.0,
|
|
||||||
height: float = 120.0,
|
|
||||||
max_volume: float = 500000.0,
|
|
||||||
barcode: str = None,
|
|
||||||
) -> Bottle:
|
|
||||||
return Bottle(
|
|
||||||
name=name,
|
|
||||||
diameter=diameter,
|
|
||||||
height=height,
|
|
||||||
max_volume=max_volume,
|
|
||||||
barcode=barcode,
|
|
||||||
model="My_Reagent_Bottle",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bottle 参数:**
|
|
||||||
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
|
|
||||||
- `diameter`: 瓶体直径 (mm)
|
|
||||||
- `height`: 瓶体高度 (mm)
|
|
||||||
- `max_volume`: 最大容积(**μL**,500mL = 500000)
|
|
||||||
- `barcode`: 条形码(可选)
|
|
||||||
|
|
||||||
### BottleCarrier
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pylabrobot.resources import ResourceHolder
|
|
||||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
|
||||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
|
|
||||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
|
||||||
sites = create_ordered_items_2d(
|
|
||||||
klass=ResourceHolder,
|
|
||||||
num_items_x=3, num_items_y=2,
|
|
||||||
dx=10.0, dy=10.0, dz=5.0,
|
|
||||||
item_dx=42.0, item_dy=35.0,
|
|
||||||
size_x=20.0, size_y=20.0, size_z=50.0,
|
|
||||||
)
|
|
||||||
# 子 site 用 name 作为前缀
|
|
||||||
for k, v in sites.items():
|
|
||||||
v.name = f"{name}_{v.name}"
|
|
||||||
|
|
||||||
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 时用 name 作为前缀
|
|
||||||
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
|
|
||||||
for i in range(6):
|
|
||||||
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
|
|
||||||
return carrier
|
|
||||||
```
|
|
||||||
|
|
||||||
### WareHouse / Deck 放置位
|
|
||||||
|
|
||||||
WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
|
|
||||||
|
|
||||||
#### WareHouse(使用 warehouse_factory)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.resources.warehouse import warehouse_factory
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
|
|
||||||
def my_warehouse_4x4(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, # 第一个 slot 的起始偏移
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
|
|
||||||
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
|
|
||||||
model="my_warehouse_4x4",
|
|
||||||
col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05)
|
|
||||||
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`warehouse_factory` 参数说明:
|
|
||||||
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm)
|
|
||||||
- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量
|
|
||||||
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
|
|
||||||
- `layout`:影响 slot 标签和坐标映射
|
|
||||||
- `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列)
|
|
||||||
- `"col-major"`:A01,B01,...,A02,B02,...(列优先)
|
|
||||||
- `"vertical-col-major"`:竖向排列,y 坐标反向
|
|
||||||
|
|
||||||
#### Deck 组装 WareHouse
|
|
||||||
|
|
||||||
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pylabrobot.resources import Deck, Coordinate
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
|
|
||||||
class MyStation_Deck(Deck):
|
|
||||||
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
|
|
||||||
category="deck", setup=False, **kwargs) -> 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 = {
|
|
||||||
"堆栈1左": my_warehouse_4x4("堆栈1左"),
|
|
||||||
"堆栈1右": my_warehouse_4x4("堆栈1右"),
|
|
||||||
}
|
|
||||||
self.warehouse_locations = {
|
|
||||||
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
|
|
||||||
"堆栈1右": 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])
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Site 模式(前端定向放置)
|
|
||||||
|
|
||||||
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import collections
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from pylabrobot.resources import Deck, Resource, Coordinate
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
|
|
||||||
class MyLabDeck(Deck):
|
|
||||||
# 根据设备台面实测批量计算各 slot 坐标偏移
|
|
||||||
_DEFAULT_SITE_POSITIONS = [
|
|
||||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
|
||||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
|
||||||
]
|
|
||||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
|
|
||||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
|
|
||||||
|
|
||||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
|
||||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
|
||||||
super().__init__(size_x, size_y, size_z, name)
|
|
||||||
if sites is not None:
|
|
||||||
self.sites = [dict(s) for s in sites]
|
|
||||||
else:
|
|
||||||
self.sites = []
|
|
||||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
|
||||||
self.sites.append({
|
|
||||||
"label": f"T{i + 1}", # 前端显示的槽位标签
|
|
||||||
"visible": True, # 是否在前端可见
|
|
||||||
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
|
|
||||||
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
|
|
||||||
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
|
|
||||||
})
|
|
||||||
self._ordering = collections.OrderedDict(
|
|
||||||
(site["label"], None) for site in self.sites
|
|
||||||
)
|
|
||||||
|
|
||||||
def assign_child_resource(self, resource: Resource,
|
|
||||||
location: Optional[Coordinate] = None,
|
|
||||||
reassign: bool = True,
|
|
||||||
spot: Optional[int] = None):
|
|
||||||
idx = spot
|
|
||||||
if spot is None:
|
|
||||||
for i, site in enumerate(self.sites):
|
|
||||||
if site.get("label") == resource.name:
|
|
||||||
idx = i
|
|
||||||
break
|
|
||||||
if idx is None:
|
|
||||||
for i in range(len(self.sites)):
|
|
||||||
if self._get_site_resource(i) is None:
|
|
||||||
idx = i
|
|
||||||
break
|
|
||||||
if idx is None:
|
|
||||||
raise ValueError(f"No available site for '{resource.name}'")
|
|
||||||
loc = Coordinate(**self.sites[idx]["position"])
|
|
||||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
data = super().serialize()
|
|
||||||
sites_out = []
|
|
||||||
for i, site in enumerate(self.sites):
|
|
||||||
occupied = self._get_site_resource(i)
|
|
||||||
sites_out.append({
|
|
||||||
"label": site["label"],
|
|
||||||
"visible": site.get("visible", True),
|
|
||||||
"occupied_by": occupied.name if occupied else None,
|
|
||||||
"position": site["position"],
|
|
||||||
"size": site["size"],
|
|
||||||
"content_type": site["content_type"],
|
|
||||||
})
|
|
||||||
data["sites"] = sites_out
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
**Site 字段说明:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
|
|
||||||
| `visible` | bool | 是否在前端可见 |
|
|
||||||
| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 |
|
|
||||||
| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) |
|
|
||||||
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
|
|
||||||
|
|
||||||
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文件位置
|
|
||||||
|
|
||||||
```
|
|
||||||
unilabos/resources/
|
|
||||||
├── <project>/ # 按项目分组
|
|
||||||
│ ├── bottles.py # Bottle 工厂函数
|
|
||||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
|
||||||
│ ├── warehouses.py # WareHouse 工厂函数
|
|
||||||
│ └── decks.py # Deck 类定义
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 资源可导入
|
|
||||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
|
||||||
|
|
||||||
# 启动测试(AST 自动扫描)
|
|
||||||
unilab -g <graph>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键路径
|
|
||||||
|
|
||||||
| 内容 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
|
||||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
|
||||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
|
||||||
| 装饰器定义 | `unilabos/registry/decorators.py` |
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# 资源高级参考
|
|
||||||
|
|
||||||
本文件是 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` |
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-workstation
|
|
||||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Uni-Lab-OS 工作站接入指南
|
|
||||||
|
|
||||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 工作站类型
|
|
||||||
|
|
||||||
| 类型 | 基类 | 适用场景 |
|
|
||||||
| ------------------- | ----------------- | ---------------------------------- |
|
|
||||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
|
|
||||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
|
|
||||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## @device 装饰器(工作站)
|
|
||||||
|
|
||||||
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@device(
|
|
||||||
id="my_workstation", # 注册表唯一标识(必填)
|
|
||||||
category=["workstation"], # 分类标签
|
|
||||||
description="我的工作站",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 工作站驱动模板
|
|
||||||
|
|
||||||
### 模板 A:基于外部系统的工作站
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from pylabrobot.resources import Deck
|
|
||||||
|
|
||||||
from unilabos.registry.decorators import device, topic_config, not_action
|
|
||||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
|
||||||
|
|
||||||
try:
|
|
||||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
|
||||||
except ImportError:
|
|
||||||
ROS2WorkstationNode = None
|
|
||||||
|
|
||||||
|
|
||||||
@device(id="my_workstation", category=["workstation"], description="我的工作站")
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
_ros_node: "ROS2WorkstationNode"
|
|
||||||
|
|
||||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
|
||||||
super().__init__(deck=deck, **kwargs)
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger("MyWorkstation")
|
|
||||||
self.api_host = self.config.get("api_host", "")
|
|
||||||
self._status = "Idle"
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node: "ROS2WorkstationNode"):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
self._ros_node = ros_node
|
|
||||||
|
|
||||||
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
|
|
||||||
@topic_config()
|
|
||||||
def workflow_sequence(self) -> str:
|
|
||||||
return "[]"
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def material_info(self) -> str:
|
|
||||||
return "{}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 模板 B:Protocol 工作站
|
|
||||||
|
|
||||||
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
|
||||||
```
|
|
||||||
|
|
||||||
在图文件中配置 `protocol_type` 即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 子设备访问(sub_devices)
|
|
||||||
|
|
||||||
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 在工站驱动类的方法中访问子设备
|
|
||||||
sub = self._ros_node.sub_devices["pump_1"]
|
|
||||||
|
|
||||||
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
|
|
||||||
sub.driver_instance.some_method(arg1, arg2)
|
|
||||||
|
|
||||||
# .ros_node_instance — 子设备的 ROS2 节点实例
|
|
||||||
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
|
|
||||||
```
|
|
||||||
|
|
||||||
**常见用法**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
def my_protocol(self, **kwargs):
|
|
||||||
# 获取子设备驱动实例
|
|
||||||
pump = self._ros_node.sub_devices["pump_1"].driver_instance
|
|
||||||
heater = self._ros_node.sub_devices["heater_1"].driver_instance
|
|
||||||
|
|
||||||
# 直接调用子设备方法
|
|
||||||
pump.aspirate(volume=100)
|
|
||||||
heater.set_temperature(80)
|
|
||||||
```
|
|
||||||
|
|
||||||
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 硬件通信接口(hardware_interface)
|
|
||||||
|
|
||||||
硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
|
|
||||||
|
|
||||||
### 工作原理
|
|
||||||
|
|
||||||
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`):
|
|
||||||
|
|
||||||
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
|
|
||||||
|
|
||||||
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
|
|
||||||
|
|
||||||
```
|
|
||||||
hardware_interface = d.ros_node_instance._hardware_interface
|
|
||||||
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
|
|
||||||
```
|
|
||||||
|
|
||||||
1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
|
|
||||||
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
|
|
||||||
2. 从通信设备获取真正的 `read`/`write` 方法
|
|
||||||
3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
|
|
||||||
|
|
||||||
因此:
|
|
||||||
|
|
||||||
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`)
|
|
||||||
- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备)
|
|
||||||
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
|
|
||||||
|
|
||||||
### HardwareInterface 参数说明
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import HardwareInterface
|
|
||||||
|
|
||||||
HardwareInterface(
|
|
||||||
name="hardware_interface", # __init__ 中接收通信实例的属性名
|
|
||||||
read="send_command", # 通信设备上暴露的读方法名
|
|
||||||
write="send_command", # 通信设备上暴露的写方法名
|
|
||||||
extra_info=["list_ports"], # 可选:额外暴露的方法
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
|
|
||||||
|
|
||||||
### 示例 1:泵(name="hardware_interface")
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device, HardwareInterface
|
|
||||||
|
|
||||||
@device(
|
|
||||||
id="my_pump",
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
hardware_interface=HardwareInterface(
|
|
||||||
name="hardware_interface",
|
|
||||||
read="send_command",
|
|
||||||
write="send_command",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class MyPump:
|
|
||||||
def __init__(self, port=None, address="1", **kwargs):
|
|
||||||
# name="hardware_interface" → 系统替换 self.hardware_interface
|
|
||||||
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
|
|
||||||
self.address = address
|
|
||||||
|
|
||||||
def send_command(self, command: str):
|
|
||||||
full_command = f"/{self.address}{command}\r\n"
|
|
||||||
self.hardware_interface.write(bytearray(full_command, "ascii"))
|
|
||||||
return self.hardware_interface.read_until(b"\n")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 示例 2:电磁阀(name="io_device_port",自定义属性名)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@device(
|
|
||||||
id="solenoid_valve",
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
hardware_interface=HardwareInterface(
|
|
||||||
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
|
|
||||||
read="read_io_coil",
|
|
||||||
write="write_io_coil",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class SolenoidValve:
|
|
||||||
def __init__(self, io_device_port: str = None, **kwargs):
|
|
||||||
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
|
|
||||||
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
|
|
||||||
```
|
|
||||||
|
|
||||||
### Serial 通信设备(class="serial")
|
|
||||||
|
|
||||||
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from serial import Serial, SerialException
|
|
||||||
from threading import Lock
|
|
||||||
|
|
||||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
|
||||||
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
|
|
||||||
self.port = port
|
|
||||||
self.baudrate = baudrate
|
|
||||||
self._hardware_interface = {
|
|
||||||
"name": "hardware_interface",
|
|
||||||
"write": "send_command",
|
|
||||||
"read": "read_data",
|
|
||||||
}
|
|
||||||
self._query_lock = Lock()
|
|
||||||
|
|
||||||
self.hardware_interface = Serial(baudrate=baudrate, port=port)
|
|
||||||
|
|
||||||
BaseROS2DeviceNode.__init__(
|
|
||||||
self, driver_instance=self, registry_name=registry_name,
|
|
||||||
device_id=device_id, status_types={}, action_value_mappings={},
|
|
||||||
hardware_interface=self._hardware_interface, print_publish=False,
|
|
||||||
)
|
|
||||||
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
|
|
||||||
|
|
||||||
def send_command(self, command: str):
|
|
||||||
with self._query_lock:
|
|
||||||
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
|
|
||||||
return self.hardware_interface.read_until(b"\n").decode()
|
|
||||||
|
|
||||||
def read_data(self):
|
|
||||||
with self._query_lock:
|
|
||||||
return self.hardware_interface.read_until(b"\n").decode()
|
|
||||||
```
|
|
||||||
|
|
||||||
在图文件中使用 `"class": "serial"` 即可创建串口代理:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "serial_pump",
|
|
||||||
"class": "serial",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "COM7", "baudrate": 9600 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 图文件配置
|
|
||||||
|
|
||||||
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "my_station",
|
|
||||||
"class": "workstation",
|
|
||||||
"children": ["serial_pump", "pump_1", "pump_2"],
|
|
||||||
"config": { "protocol_type": ["PumpTransferProtocol"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "serial_pump",
|
|
||||||
"class": "serial",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "COM7", "baudrate": 9600 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pump_1",
|
|
||||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pump_2",
|
|
||||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"source": "pump_1",
|
|
||||||
"target": "serial_pump",
|
|
||||||
"type": "communication",
|
|
||||||
"port": { "pump_1": "port", "serial_pump": "port" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "pump_2",
|
|
||||||
"target": "serial_pump",
|
|
||||||
"type": "communication",
|
|
||||||
"port": { "pump_2": "port", "serial_pump": "port" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通信协议速查
|
|
||||||
|
|
||||||
| 协议 | config 参数 | 依赖包 | 通信设备 class |
|
|
||||||
| -------------------- | ------------------------------ | ---------- | -------------------------- |
|
|
||||||
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
|
|
||||||
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
|
||||||
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
|
||||||
| TCP Socket | `host`, `port` | stdlib | 自定义 |
|
|
||||||
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
|
|
||||||
|
|
||||||
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deck 与物料生命周期
|
|
||||||
|
|
||||||
### 1. Deck 入参与两种初始化模式
|
|
||||||
|
|
||||||
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。
|
|
||||||
|
|
||||||
有两种初始化模式:
|
|
||||||
|
|
||||||
#### init 初始化(推荐)
|
|
||||||
|
|
||||||
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
|
|
||||||
|
|
||||||
```json
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
|
||||||
"_resource_child_name": "PRCXI_Deck"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### deserialize 初始化
|
|
||||||
|
|
||||||
`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"data": {
|
|
||||||
"_resource_child_name": "YB_Bioyond_Deck",
|
|
||||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
没有特殊需求时推荐 init 初始化。
|
|
||||||
|
|
||||||
#### config.deck 字段说明
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) |
|
|
||||||
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
|
|
||||||
|
|
||||||
#### 设备 __init__ 接收
|
|
||||||
|
|
||||||
```python
|
|
||||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
|
||||||
super().__init__(deck=deck, **kwargs)
|
|
||||||
# deck 已经是反序列化后的 Deck 实例
|
|
||||||
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Deck 节点(图文件中)
|
|
||||||
|
|
||||||
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "PRCXI_Deck",
|
|
||||||
"parent": "PRCXI",
|
|
||||||
"type": "deck",
|
|
||||||
"class": "",
|
|
||||||
"children": [],
|
|
||||||
"config": {
|
|
||||||
"type": "PRCXI9300Deck",
|
|
||||||
"size_x": 542, "size_y": 374, "size_z": 0,
|
|
||||||
"category": "deck",
|
|
||||||
"sites": [...]
|
|
||||||
},
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
|
|
||||||
- `children` 初始为空时,由同步器或手动初始化填充
|
|
||||||
- `config.type` 填 Deck 类名
|
|
||||||
|
|
||||||
### 2. Deck 为空时自行初始化
|
|
||||||
|
|
||||||
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
if self.deck and not self.deck.children:
|
|
||||||
self._initialize_default_deck()
|
|
||||||
|
|
||||||
def _initialize_default_deck(self):
|
|
||||||
from my_labware import My_TipRack, My_Plate
|
|
||||||
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
|
|
||||||
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 物料双向同步
|
|
||||||
|
|
||||||
当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
|
||||||
|
|
||||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
|
||||||
def sync_from_external(self) -> bool:
|
|
||||||
"""从外部系统同步到 self.workstation.deck"""
|
|
||||||
external_data = self._query_external_materials()
|
|
||||||
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
|
|
||||||
for item in external_data:
|
|
||||||
cls = self._resolve_resource_class(item["type"])
|
|
||||||
resource = cls(name=item["name"], **item["params"])
|
|
||||||
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sync_to_external(self, resource) -> bool:
|
|
||||||
"""将 UniLab 侧物料变更同步到外部系统"""
|
|
||||||
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
|
|
||||||
external_format = self._convert_to_external(resource)
|
|
||||||
return self._push_to_external(external_format)
|
|
||||||
|
|
||||||
def handle_external_change(self, change_info) -> bool:
|
|
||||||
"""处理外部系统主动推送的变更"""
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
同步策略取决于业务场景:
|
|
||||||
|
|
||||||
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
|
|
||||||
- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
|
|
||||||
|
|
||||||
在工作站 `post_init` 中初始化同步器:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
self.resource_synchronizer = MyResourceSynchronizer(self)
|
|
||||||
self.resource_synchronizer.sync_from_external()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 序列化与持久化(serialize / serialize_state)
|
|
||||||
|
|
||||||
资源类需正确实现序列化,系统据此完成持久化和前端同步。
|
|
||||||
|
|
||||||
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyDeck(Deck):
|
|
||||||
def __init__(self, name, size_x, size_y, size_z,
|
|
||||||
sites=None, # serialize() 输出的字段
|
|
||||||
rotation=None, # serialize() 输出的字段
|
|
||||||
barcode=None, # serialize() 输出的字段
|
|
||||||
**kwargs): # 兜底:接受所有未知的 serialize 字段
|
|
||||||
super().__init__(size_x, size_y, size_z, name)
|
|
||||||
# ...
|
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
data = super().serialize()
|
|
||||||
data["sites"] = [...] # 自定义字段
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyPlate(Plate):
|
|
||||||
def __init__(self, name, size_x, size_y, size_z,
|
|
||||||
material_info=None, **kwargs):
|
|
||||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
|
||||||
self._unilabos_state = {}
|
|
||||||
if material_info:
|
|
||||||
self._unilabos_state["Material"] = material_info
|
|
||||||
|
|
||||||
def serialize_state(self) -> Dict[str, Any]:
|
|
||||||
data = super().serialize_state()
|
|
||||||
data.update(self._unilabos_state)
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
关键要点:
|
|
||||||
|
|
||||||
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`)
|
|
||||||
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
|
|
||||||
- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None)
|
|
||||||
|
|
||||||
### 5. 子物料自动同步
|
|
||||||
|
|
||||||
子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
|
|
||||||
|
|
||||||
### 6. 图文件配置(参考 prcxi_9320_slim.json)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "my_station",
|
|
||||||
"type": "device",
|
|
||||||
"class": "my_workstation",
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"_resource_type": "unilabos.resources.my_module:MyDeck",
|
|
||||||
"_resource_child_name": "my_deck"
|
|
||||||
},
|
|
||||||
"host": "10.20.30.1",
|
|
||||||
"port": 9999
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "my_deck",
|
|
||||||
"parent": "my_station",
|
|
||||||
"type": "deck",
|
|
||||||
"class": "",
|
|
||||||
"children": [],
|
|
||||||
"config": {
|
|
||||||
"type": "MyLabDeck",
|
|
||||||
"size_x": 542,
|
|
||||||
"size_y": 374,
|
|
||||||
"size_z": 0,
|
|
||||||
"category": "deck",
|
|
||||||
"sites": [
|
|
||||||
{
|
|
||||||
"label": "T1",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": { "x": 0, "y": 0, "z": 0 },
|
|
||||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
|
||||||
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"edges": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Deck 节点要点:
|
|
||||||
|
|
||||||
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`)
|
|
||||||
- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取)
|
|
||||||
- `children` 初始为空(由同步器或手动初始化填充)
|
|
||||||
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 子设备
|
|
||||||
|
|
||||||
子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。
|
|
||||||
|
|
||||||
子设备约束:
|
|
||||||
|
|
||||||
- 图文件中 `parent` 指向工作站 ID
|
|
||||||
- 在工作站 `children` 数组中列出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键规则
|
|
||||||
|
|
||||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
|
|
||||||
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
|
|
||||||
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
|
|
||||||
4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external`
|
|
||||||
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
|
||||||
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
|
||||||
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
|
||||||
8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup`
|
|
||||||
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 模块可导入
|
|
||||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
|
||||||
|
|
||||||
# 启动测试(AST 自动扫描)
|
|
||||||
unilab -g <graph>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 现有工作站参考
|
|
||||||
|
|
||||||
| 工作站 | 驱动类 | 类型 |
|
|
||||||
| -------------- | ----------------------------- | -------- |
|
|
||||||
| Protocol 通用 | `ProtocolNode` | Protocol |
|
|
||||||
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
|
|
||||||
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
|
||||||
|
|
||||||
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
# 工作站高级模式参考
|
|
||||||
|
|
||||||
本文件是 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)
|
|
||||||
```
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
---
|
|
||||||
name: batch-insert-reagent
|
|
||||||
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 批量录入试剂 Skill
|
|
||||||
|
|
||||||
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
|
|
||||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
|
||||||
|
|
||||||
### 1. ak / sk → AUTH
|
|
||||||
|
|
||||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
|
||||||
|
|
||||||
生成 AUTH token(任选一种方式):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式一:Python 一行生成
|
|
||||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
|
||||||
|
|
||||||
# 方式二:手动计算
|
|
||||||
# base64(ak:sk) → Authorization: Lab <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
|
||||||
|-------------|------|
|
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
|
|
||||||
确认后设置:
|
|
||||||
```bash
|
|
||||||
BASE="<根据 addr 确定的 URL>"
|
|
||||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**两项全部就绪后才可发起 API 请求。**
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
|
|
||||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
|
||||||
|
|
||||||
## 请求约定
|
|
||||||
|
|
||||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
|
||||||
|
|
||||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
记住 `data.uuid` 为 `lab_uuid`。
|
|
||||||
|
|
||||||
### 2. 录入试剂
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|
||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"lab_uuid": "<lab_uuid>",
|
|
||||||
"cas": "<CAS号>",
|
|
||||||
"name": "<试剂名称>",
|
|
||||||
"molecular_formula": "<分子式>",
|
|
||||||
"smiles": "<SMILES>",
|
|
||||||
"stock_in_quantity": <入库数量>,
|
|
||||||
"unit": "<单位字符串>",
|
|
||||||
"supplier": "<供应商>",
|
|
||||||
"production_date": "<生产日期 ISO 8601>",
|
|
||||||
"expiry_date": "<过期日期 ISO 8601>"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
返回成功时包含试剂 UUID:
|
|
||||||
```json
|
|
||||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 试剂字段说明
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
|
||||||
|------|------|------|------|------|
|
|
||||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
|
||||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
|
||||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
|
||||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
|
||||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
|
||||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
|
||||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
|
||||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
|
||||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
|
||||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
|
||||||
|
|
||||||
### unit 单位值
|
|
||||||
|
|
||||||
| 值 | 单位 |
|
|
||||||
|------|------|
|
|
||||||
| `"mL"` | 毫升 |
|
|
||||||
| `"L"` | 升 |
|
|
||||||
| `"g"` | 克 |
|
|
||||||
| `"kg"` | 千克 |
|
|
||||||
| `"瓶"` | 瓶 |
|
|
||||||
|
|
||||||
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 批量录入策略
|
|
||||||
|
|
||||||
### 方式一:用户提供 JSON 数组
|
|
||||||
|
|
||||||
用户一次性给出多条试剂数据:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
|
|
||||||
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent 自动为每条补充 `lab_uuid`、`production_date`、`expiry_date` 等字段后逐条提交。
|
|
||||||
|
|
||||||
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
|
|
||||||
|
|
||||||
### 方式二:用户逐个描述
|
|
||||||
|
|
||||||
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇,Sigma 的」),agent 自行补全字段:
|
|
||||||
|
|
||||||
1. 根据名称查找 CAS 号、分子式、SMILES(参考下方速查表或自行推断)
|
|
||||||
2. 构建完整的请求体
|
|
||||||
3. 向用户确认后提交
|
|
||||||
|
|
||||||
### 方式三:从 CSV/表格批量导入
|
|
||||||
|
|
||||||
用户提供 CSV 或表格文件路径,agent 读取并解析:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 期望的 CSV 格式(首行为表头)
|
|
||||||
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
|
|
||||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
|
||||||
```
|
|
||||||
|
|
||||||
### 执行与汇报
|
|
||||||
|
|
||||||
每次 API 调用后:
|
|
||||||
1. 检查返回 `code`(0 = 成功)
|
|
||||||
2. 记录成功/失败数量
|
|
||||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
|
||||||
4. 如有失败,列出失败的试剂名称和错误信息
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见试剂速查表
|
|
||||||
|
|
||||||
| 名称 | CAS | 分子式 | SMILES |
|
|
||||||
|------|-----|--------|--------|
|
|
||||||
| 水 | 7732-18-3 | H2O | O |
|
|
||||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
|
||||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
|
||||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
|
||||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
|
||||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
|
||||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
|
||||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
|
||||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
|
||||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
|
||||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
|
||||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
|
||||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
|
||||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
|
||||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
|
||||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
|
||||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
|
||||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
|
||||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
|
||||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
|
||||||
|
|
||||||
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整工作流 Checklist
|
|
||||||
|
|
||||||
```
|
|
||||||
Task Progress:
|
|
||||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
|
||||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
|
||||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
|
||||||
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件)
|
|
||||||
- [ ] Step 5: 补全缺失字段(CAS、分子式、SMILES 等)
|
|
||||||
- [ ] Step 6: 向用户确认待录入的试剂列表
|
|
||||||
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid)
|
|
||||||
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整示例
|
|
||||||
|
|
||||||
用户说:「帮我录入 3 种试剂:500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
|
|
||||||
|
|
||||||
Agent 构建的请求序列:
|
|
||||||
|
|
||||||
```json
|
|
||||||
// 第 1 条
|
|
||||||
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
|
||||||
|
|
||||||
// 第 2 条
|
|
||||||
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
|
||||||
|
|
||||||
// 第 3 条
|
|
||||||
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
|
||||||
```
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
---
|
|
||||||
name: batch-submit-experiment
|
|
||||||
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds. Use when the user wants to submit experiments, create notebooks, batch run workflows, or mentions 提交实验/批量实验/notebook/实验轮次.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 批量提交实验指南
|
|
||||||
|
|
||||||
通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
|
|
||||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
|
||||||
|
|
||||||
### 1. ak / sk → AUTH
|
|
||||||
|
|
||||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
|
||||||
|
|
||||||
生成 AUTH token(任选一种方式):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式一:Python 一行生成
|
|
||||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
|
||||||
|
|
||||||
# 方式二:手动计算
|
|
||||||
# base64(ak:sk) → Authorization: Lab <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
|
||||||
|-------------|------|
|
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
|
|
||||||
确认后设置:
|
|
||||||
```bash
|
|
||||||
BASE="<根据 addr 确定的 URL>"
|
|
||||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. req_device_registry_upload.json(设备注册表)
|
|
||||||
|
|
||||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
|
||||||
|
|
||||||
按优先级搜索:
|
|
||||||
|
|
||||||
```
|
|
||||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
|
||||||
<workspace 根目录>/req_device_registry_upload.json
|
|
||||||
```
|
|
||||||
|
|
||||||
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
|
|
||||||
|
|
||||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
|
||||||
|
|
||||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
|
||||||
|
|
||||||
### 4. workflow_uuid(目标工作流)
|
|
||||||
|
|
||||||
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #2 列出可用 workflow 供选择。
|
|
||||||
|
|
||||||
**四项全部就绪后才可开始。**
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
|
|
||||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
|
||||||
|
|
||||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
|
||||||
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
|
||||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #3 获取)
|
|
||||||
|
|
||||||
## 请求约定
|
|
||||||
|
|
||||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
|
||||||
|
|
||||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
|
||||||
>
|
|
||||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
记住 `data.uuid` 为 `lab_uuid`。
|
|
||||||
|
|
||||||
### 2. 列出可用 workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。
|
|
||||||
|
|
||||||
### 3. 获取 workflow 模板详情
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
|
||||||
- 每个 action 节点的 `node_uuid`
|
|
||||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
|
||||||
- 每个节点的动作名(`node_template_name`)
|
|
||||||
- 每个节点的现有参数(`param`)
|
|
||||||
|
|
||||||
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
|
|
||||||
|
|
||||||
### 4. 提交实验(创建 notebook)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
|
||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
|
||||||
-d '<request_body>'
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体结构:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"lab_uuid": "<lab_uuid>",
|
|
||||||
"workflow_uuid": "<workflow_uuid>",
|
|
||||||
"name": "<实验名称>",
|
|
||||||
"node_params": [
|
|
||||||
{
|
|
||||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
|
||||||
"datas": [
|
|
||||||
{
|
|
||||||
"node_uuid": "<workflow中的节点UUID>",
|
|
||||||
"param": {},
|
|
||||||
"sample_params": [
|
|
||||||
{
|
|
||||||
"container_uuid": "<容器UUID>",
|
|
||||||
"sample_value": {
|
|
||||||
"liquid_names": "<液体名称>",
|
|
||||||
"volumes": 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notebook 请求体详解
|
|
||||||
|
|
||||||
### node_params 结构
|
|
||||||
|
|
||||||
`node_params` 是一个数组,**每个元素代表一轮实验**:
|
|
||||||
|
|
||||||
- 要跑 2 轮 → `node_params` 有 2 个元素
|
|
||||||
- 要跑 N 轮 → `node_params` 有 N 个元素
|
|
||||||
|
|
||||||
### 每轮的字段
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
|
||||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
|
||||||
|
|
||||||
### datas 中每个节点
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #3 获取) |
|
|
||||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
|
||||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
|
||||||
|
|
||||||
### sample_params 中每条
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `container_uuid` | string | 容器 UUID |
|
|
||||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 从本地注册表生成 param 模板
|
|
||||||
|
|
||||||
### 自动方式 — 运行脚本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/gen_notebook_params.py \
|
|
||||||
--auth <token> \
|
|
||||||
--base <BASE_URL> \
|
|
||||||
--workflow-uuid <workflow_uuid> \
|
|
||||||
[--registry <path/to/req_device_registry_upload.json>] \
|
|
||||||
[--rounds <轮次数>] \
|
|
||||||
[--output <输出文件路径>]
|
|
||||||
```
|
|
||||||
|
|
||||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
|
||||||
|
|
||||||
脚本会:
|
|
||||||
1. 调用 workflow detail API 获取所有 action 节点
|
|
||||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
|
||||||
3. 生成 `notebook_template.json`,包含:
|
|
||||||
- 完整 `node_params` 骨架
|
|
||||||
- 每个节点的 param 字段及类型说明
|
|
||||||
- `_schema_info` 辅助信息(不提交,仅供参考)
|
|
||||||
|
|
||||||
### 手动方式
|
|
||||||
|
|
||||||
如果脚本不可用或注册表不存在:
|
|
||||||
|
|
||||||
1. 调用 API #3 获取 workflow 详情
|
|
||||||
2. 找到每个 action 节点的 `node_uuid`
|
|
||||||
3. 在本地注册表中查找对应设备的 `action_value_mappings`:
|
|
||||||
```
|
|
||||||
resources[].id == <device_id>
|
|
||||||
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
|
|
||||||
```
|
|
||||||
4. 将 schema 中的 properties 作为 `param` 的字段模板
|
|
||||||
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
|
|
||||||
|
|
||||||
### 注册表结构参考
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"id": "liquid_handler.prcxi",
|
|
||||||
"class": {
|
|
||||||
"module": "unilabos.devices.xxx:ClassName",
|
|
||||||
"action_value_mappings": {
|
|
||||||
"transfer_liquid": {
|
|
||||||
"type": "LiquidHandlerTransfer",
|
|
||||||
"schema": {
|
|
||||||
"properties": {
|
|
||||||
"goal": {
|
|
||||||
"properties": {
|
|
||||||
"asp_vols": {"type": "array", "items": {"type": "number"}},
|
|
||||||
"sources": {"type": "array"}
|
|
||||||
},
|
|
||||||
"required": ["asp_vols", "sources"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"goal_default": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整工作流 Checklist
|
|
||||||
|
|
||||||
```
|
|
||||||
Task Progress:
|
|
||||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
|
||||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
|
||||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
|
||||||
- [ ] Step 4: 确认 workflow_uuid(用户提供或从 GET #2 列表选择)
|
|
||||||
- [ ] Step 5: GET workflow detail (#3) → 提取各节点 uuid、设备ID、动作名
|
|
||||||
- [ ] Step 6: 定位本地注册表 req_device_registry_upload.json
|
|
||||||
- [ ] Step 7: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
|
||||||
- [ ] Step 8: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
|
||||||
- [ ] Step 9: 构建完整请求体 → POST /lab/notebook 提交
|
|
||||||
- [ ] Step 10: 检查返回结果,确认提交成功
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
|
|
||||||
|
|
||||||
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
|
|
||||||
|
|
||||||
### Q: 多轮实验的参数完全不同吗?
|
|
||||||
|
|
||||||
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
|
|
||||||
|
|
||||||
### Q: 如何获取 sample_uuids 和 container_uuid?
|
|
||||||
|
|
||||||
这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
|
|
||||||
|
|
||||||
选项:
|
|
||||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
|
||||||
--base <url> API 基础 URL(如 https://uni-lab.test.bohrium.com)
|
|
||||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
|
||||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
|
||||||
--rounds <n> 实验轮次数(默认 1)
|
|
||||||
--output <path> 输出模板文件路径(默认 notebook_template.json)
|
|
||||||
--dump-response 打印 workflow detail API 的原始响应(调试用)
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python gen_notebook_params.py \\
|
|
||||||
--auth YTFmZDlkNGUtxxxx \\
|
|
||||||
--base https://uni-lab.test.bohrium.com \\
|
|
||||||
--workflow-uuid abc-123-def \\
|
|
||||||
--rounds 2
|
|
||||||
"""
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
|
|
||||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
|
||||||
|
|
||||||
|
|
||||||
def find_registry(explicit_path=None):
|
|
||||||
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
|
|
||||||
if explicit_path:
|
|
||||||
if os.path.isfile(explicit_path):
|
|
||||||
return explicit_path
|
|
||||||
if os.path.isdir(explicit_path):
|
|
||||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
|
||||||
if os.path.isfile(fp):
|
|
||||||
return fp
|
|
||||||
print(f"警告: 指定的注册表路径不存在: {explicit_path}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
|
||||||
REGISTRY_FILENAME,
|
|
||||||
]
|
|
||||||
for c in candidates:
|
|
||||||
if os.path.isfile(c):
|
|
||||||
return c
|
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(workspace_root, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
|
|
||||||
cwd = os.getcwd()
|
|
||||||
for _ in range(5):
|
|
||||||
parent = os.path.dirname(cwd)
|
|
||||||
if parent == cwd:
|
|
||||||
break
|
|
||||||
cwd = parent
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(cwd, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_registry(path):
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def build_registry_index(registry_data):
|
|
||||||
"""构建 device_id → action_value_mappings 的索引"""
|
|
||||||
index = {}
|
|
||||||
for res in registry_data.get("resources", []):
|
|
||||||
rid = res.get("id", "")
|
|
||||||
avm = res.get("class", {}).get("action_value_mappings", {})
|
|
||||||
if rid and avm:
|
|
||||||
index[rid] = avm
|
|
||||||
return index
|
|
||||||
|
|
||||||
|
|
||||||
def flatten_goal_schema(action_data):
|
|
||||||
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
|
|
||||||
schema = action_data.get("schema", {})
|
|
||||||
goal_schema = schema.get("properties", {}).get("goal", {})
|
|
||||||
return goal_schema if goal_schema else schema
|
|
||||||
|
|
||||||
|
|
||||||
def build_param_template(goal_schema):
|
|
||||||
"""根据 goal schema 生成 param 模板,含类型标注"""
|
|
||||||
properties = goal_schema.get("properties", {})
|
|
||||||
required = set(goal_schema.get("required", []))
|
|
||||||
template = {}
|
|
||||||
for field_name, field_def in properties.items():
|
|
||||||
if field_name == "unilabos_device_id":
|
|
||||||
continue
|
|
||||||
ftype = field_def.get("type", "any")
|
|
||||||
default = field_def.get("default")
|
|
||||||
if default is not None:
|
|
||||||
template[field_name] = default
|
|
||||||
elif ftype == "string":
|
|
||||||
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
|
|
||||||
elif ftype == "number" or ftype == "integer":
|
|
||||||
template[field_name] = 0
|
|
||||||
elif ftype == "boolean":
|
|
||||||
template[field_name] = False
|
|
||||||
elif ftype == "array":
|
|
||||||
template[field_name] = []
|
|
||||||
elif ftype == "object":
|
|
||||||
template[field_name] = {}
|
|
||||||
else:
|
|
||||||
template[field_name] = f"$TODO ({ftype})"
|
|
||||||
return template
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
|
|
||||||
"""调用 workflow detail API"""
|
|
||||||
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
|
|
||||||
req = Request(url, method="GET")
|
|
||||||
req.add_header("Authorization", f"Lab {auth_token}")
|
|
||||||
try:
|
|
||||||
with urlopen(req, timeout=30) as resp:
|
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
|
||||||
except HTTPError as e:
|
|
||||||
body = e.read().decode("utf-8", errors="replace")
|
|
||||||
print(f"API 错误 {e.code}: {body}")
|
|
||||||
return None
|
|
||||||
except URLError as e:
|
|
||||||
print(f"网络错误: {e.reason}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_nodes_from_response(response):
|
|
||||||
"""
|
|
||||||
从 workflow detail 响应中提取 action 节点列表。
|
|
||||||
适配多种可能的响应格式。
|
|
||||||
|
|
||||||
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
|
|
||||||
"""
|
|
||||||
data = response.get("data", response)
|
|
||||||
|
|
||||||
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
|
|
||||||
nodes_raw = None
|
|
||||||
for key in search_keys:
|
|
||||||
if key in data and isinstance(data[key], list):
|
|
||||||
nodes_raw = data[key]
|
|
||||||
break
|
|
||||||
|
|
||||||
if nodes_raw is None:
|
|
||||||
if isinstance(data, list):
|
|
||||||
nodes_raw = data
|
|
||||||
else:
|
|
||||||
for v in data.values():
|
|
||||||
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
|
||||||
nodes_raw = v
|
|
||||||
break
|
|
||||||
|
|
||||||
if not nodes_raw:
|
|
||||||
print("警告: 未能从响应中提取节点列表")
|
|
||||||
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
|
|
||||||
return []
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for node in nodes_raw:
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
node_uuid = (
|
|
||||||
node.get("uuid")
|
|
||||||
or node.get("node_uuid")
|
|
||||||
or node.get("id")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
resource_name = (
|
|
||||||
node.get("resource_template_name")
|
|
||||||
or node.get("device_id")
|
|
||||||
or node.get("resource_name")
|
|
||||||
or node.get("device_name")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
template_name = (
|
|
||||||
node.get("node_template_name")
|
|
||||||
or node.get("action_name")
|
|
||||||
or node.get("template_name")
|
|
||||||
or node.get("action")
|
|
||||||
or node.get("name")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
existing_param = node.get("param", {}) or {}
|
|
||||||
|
|
||||||
if node_uuid:
|
|
||||||
result.append((node_uuid, resource_name, template_name, existing_param))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def generate_template(nodes, registry_index, rounds):
|
|
||||||
"""生成 notebook 提交模板"""
|
|
||||||
node_params = []
|
|
||||||
schema_info = {}
|
|
||||||
|
|
||||||
datas_template = []
|
|
||||||
for node_uuid, resource_name, template_name, existing_param in nodes:
|
|
||||||
param_template = {}
|
|
||||||
matched = False
|
|
||||||
|
|
||||||
if resource_name and template_name and resource_name in registry_index:
|
|
||||||
avm = registry_index[resource_name]
|
|
||||||
if template_name in avm:
|
|
||||||
goal_schema = flatten_goal_schema(avm[template_name])
|
|
||||||
param_template = build_param_template(goal_schema)
|
|
||||||
goal_default = avm[template_name].get("goal_default", {})
|
|
||||||
if goal_default:
|
|
||||||
for k, v in goal_default.items():
|
|
||||||
if k in param_template and v is not None:
|
|
||||||
param_template[k] = v
|
|
||||||
matched = True
|
|
||||||
|
|
||||||
schema_info[node_uuid] = {
|
|
||||||
"device_id": resource_name,
|
|
||||||
"action_name": template_name,
|
|
||||||
"action_type": avm[template_name].get("type", ""),
|
|
||||||
"schema_properties": list(goal_schema.get("properties", {}).keys()),
|
|
||||||
"required": goal_schema.get("required", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
if not matched and existing_param:
|
|
||||||
param_template = existing_param
|
|
||||||
|
|
||||||
if not matched and not existing_param:
|
|
||||||
schema_info[node_uuid] = {
|
|
||||||
"device_id": resource_name,
|
|
||||||
"action_name": template_name,
|
|
||||||
"warning": "未在本地注册表中找到匹配的 action schema",
|
|
||||||
}
|
|
||||||
|
|
||||||
datas_template.append({
|
|
||||||
"node_uuid": node_uuid,
|
|
||||||
"param": param_template,
|
|
||||||
"sample_params": [
|
|
||||||
{
|
|
||||||
"container_uuid": "$TODO_CONTAINER_UUID",
|
|
||||||
"sample_value": {
|
|
||||||
"liquid_names": "$TODO_LIQUID_NAME",
|
|
||||||
"volumes": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
for i in range(rounds):
|
|
||||||
node_params.append({
|
|
||||||
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
|
|
||||||
"datas": copy.deepcopy(datas_template),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"lab_uuid": "$TODO_LAB_UUID",
|
|
||||||
"workflow_uuid": "$TODO_WORKFLOW_UUID",
|
|
||||||
"name": "$TODO_EXPERIMENT_NAME",
|
|
||||||
"node_params": node_params,
|
|
||||||
"_schema_info(仅参考,提交时删除)": schema_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv):
|
|
||||||
"""简单的参数解析"""
|
|
||||||
opts = {
|
|
||||||
"auth": None,
|
|
||||||
"base": None,
|
|
||||||
"workflow_uuid": None,
|
|
||||||
"registry": None,
|
|
||||||
"rounds": 1,
|
|
||||||
"output": "notebook_template.json",
|
|
||||||
"dump_response": False,
|
|
||||||
}
|
|
||||||
i = 0
|
|
||||||
while i < len(argv):
|
|
||||||
arg = argv[i]
|
|
||||||
if arg == "--auth" and i + 1 < len(argv):
|
|
||||||
opts["auth"] = argv[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif arg == "--base" and i + 1 < len(argv):
|
|
||||||
opts["base"] = argv[i + 1].rstrip("/")
|
|
||||||
i += 2
|
|
||||||
elif arg == "--workflow-uuid" and i + 1 < len(argv):
|
|
||||||
opts["workflow_uuid"] = argv[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif arg == "--registry" and i + 1 < len(argv):
|
|
||||||
opts["registry"] = argv[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif arg == "--rounds" and i + 1 < len(argv):
|
|
||||||
opts["rounds"] = int(argv[i + 1])
|
|
||||||
i += 2
|
|
||||||
elif arg == "--output" and i + 1 < len(argv):
|
|
||||||
opts["output"] = argv[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif arg == "--dump-response":
|
|
||||||
opts["dump_response"] = True
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
print(f"未知参数: {arg}")
|
|
||||||
i += 1
|
|
||||||
return opts
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
opts = parse_args(sys.argv[1:])
|
|
||||||
|
|
||||||
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
|
|
||||||
print("用法:")
|
|
||||||
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
|
|
||||||
print()
|
|
||||||
print("必需参数:")
|
|
||||||
print(" --auth <token> Lab token(base64(ak:sk))")
|
|
||||||
print(" --base <url> API 基础 URL")
|
|
||||||
print(" --workflow-uuid <uuid> 目标 workflow UUID")
|
|
||||||
print()
|
|
||||||
print("可选参数:")
|
|
||||||
print(" --registry <path> 注册表文件路径(默认自动搜索)")
|
|
||||||
print(" --rounds <n> 实验轮次数(默认 1)")
|
|
||||||
print(" --output <path> 输出文件路径(默认 notebook_template.json)")
|
|
||||||
print(" --dump-response 打印 API 原始响应")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 1. 查找并加载本地注册表
|
|
||||||
registry_path = find_registry(opts["registry"])
|
|
||||||
registry_index = {}
|
|
||||||
if registry_path:
|
|
||||||
mtime = os.path.getmtime(registry_path)
|
|
||||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
print(f"注册表: {registry_path} (生成时间: {gen_time})")
|
|
||||||
registry_data = load_registry(registry_path)
|
|
||||||
registry_index = build_registry_index(registry_data)
|
|
||||||
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
|
|
||||||
else:
|
|
||||||
print("警告: 未找到本地注册表,将跳过 param 模板生成")
|
|
||||||
print(" 提交时需要手动填写各节点的 param 字段")
|
|
||||||
|
|
||||||
# 2. 获取 workflow 详情
|
|
||||||
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
|
|
||||||
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
|
|
||||||
if not response:
|
|
||||||
print("错误: 无法获取 workflow 详情")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if opts["dump_response"]:
|
|
||||||
print("\n=== API 原始响应 ===")
|
|
||||||
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
|
|
||||||
print("=== 响应结束(截断至 5000 字符) ===\n")
|
|
||||||
|
|
||||||
# 3. 提取节点
|
|
||||||
nodes = extract_nodes_from_response(response)
|
|
||||||
if not nodes:
|
|
||||||
print("错误: 未能从 workflow 中提取任何 action 节点")
|
|
||||||
print("请使用 --dump-response 查看原始响应结构")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"\n找到 {len(nodes)} 个 action 节点:")
|
|
||||||
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
|
|
||||||
print(" " + "-" * 110)
|
|
||||||
for node_uuid, resource_name, template_name, _ in nodes:
|
|
||||||
matched = "✓" if (resource_name in registry_index and
|
|
||||||
template_name in registry_index.get(resource_name, {})) else "✗"
|
|
||||||
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
|
|
||||||
|
|
||||||
# 4. 生成模板
|
|
||||||
template = generate_template(nodes, registry_index, opts["rounds"])
|
|
||||||
template["workflow_uuid"] = opts["workflow_uuid"]
|
|
||||||
|
|
||||||
output_path = opts["output"]
|
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
|
||||||
print(f"\n模板已写入: {output_path}")
|
|
||||||
print(f" 轮次数: {opts['rounds']}")
|
|
||||||
print(f" 节点数/轮: {len(nodes)}")
|
|
||||||
print()
|
|
||||||
print("下一步:")
|
|
||||||
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
|
|
||||||
print(" 2. 删除 _schema_info 字段(仅供参考)")
|
|
||||||
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-device-skill
|
|
||||||
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 创建设备 Skill 指南
|
|
||||||
|
|
||||||
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
|
|
||||||
|
|
||||||
## 数据源
|
|
||||||
|
|
||||||
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
|
|
||||||
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
|
|
||||||
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
|
|
||||||
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
|
|
||||||
|
|
||||||
## 创建流程
|
|
||||||
|
|
||||||
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
|
|
||||||
|
|
||||||
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
|
|
||||||
|
|
||||||
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
|
|
||||||
|
|
||||||
#### 必备项 ①:ak / sk(认证凭据)
|
|
||||||
|
|
||||||
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。
|
|
||||||
|
|
||||||
获取后立即生成 AUTH token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/gen_auth.py <ak> <sk>
|
|
||||||
# 或从 config.py 提取
|
|
||||||
python ./scripts/gen_auth.py --config <config.py>
|
|
||||||
```
|
|
||||||
|
|
||||||
认证算法:`base64(ak:sk)` → `Authorization: Lab <token>`
|
|
||||||
|
|
||||||
#### 必备项 ②:--addr(目标环境)
|
|
||||||
|
|
||||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
|
||||||
|
|
||||||
| `--addr` 值 | BASE URL |
|
|
||||||
|-------------|----------|
|
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
| 其他自定义 URL | 直接使用该 URL |
|
|
||||||
|
|
||||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
|
||||||
|
|
||||||
数据文件由 `unilab` 启动时自动生成,需要定位它:
|
|
||||||
|
|
||||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
|
||||||
|
|
||||||
| 条件 | working_dir 取值 |
|
|
||||||
|------|------------------|
|
|
||||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
|
||||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
|
||||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
|
||||||
|
|
||||||
**按优先级搜索文件**:
|
|
||||||
|
|
||||||
```
|
|
||||||
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
|
|
||||||
<推断的 working_dir>/req_device_registry_upload.json
|
|
||||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
|
||||||
```
|
|
||||||
|
|
||||||
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
|
|
||||||
|
|
||||||
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。
|
|
||||||
|
|
||||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
|
|
||||||
|
|
||||||
#### 必备项 ④:目标设备
|
|
||||||
|
|
||||||
用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。
|
|
||||||
|
|
||||||
如果用户不确定,运行提取脚本列出所有设备供选择:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 完整示例
|
|
||||||
|
|
||||||
用户提供:
|
|
||||||
|
|
||||||
```
|
|
||||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
|
||||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
|
||||||
--addr test
|
|
||||||
--port 8003
|
|
||||||
--disable_browser
|
|
||||||
```
|
|
||||||
|
|
||||||
从中提取:
|
|
||||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
|
||||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
|
||||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
|
||||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
|
||||||
|
|
||||||
**四项全部就绪后才进入 Step 1。**
|
|
||||||
|
|
||||||
### Step 1 — 列出可用设备
|
|
||||||
|
|
||||||
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
|
|
||||||
python ./scripts/extract_device_actions.py
|
|
||||||
|
|
||||||
# 指定注册表文件路径
|
|
||||||
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
|
|
||||||
|
|
||||||
### Step 2 — 提取 Action Schema
|
|
||||||
|
|
||||||
用户选择设备后,运行提取脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
|
||||||
|
|
||||||
每个 action 生成一个 JSON 文件,包含:
|
|
||||||
- `type` — 作为 API 调用的 `action_type`
|
|
||||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
|
||||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
|
||||||
- `goal_default` — 默认值
|
|
||||||
|
|
||||||
### Step 3 — 写 action-index.md
|
|
||||||
|
|
||||||
按模板为每个 action 写条目:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### `<action_name>`
|
|
||||||
|
|
||||||
<用途描述(一句话)>
|
|
||||||
|
|
||||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
|
||||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
|
||||||
- **可选参数**: `param3`, `param4`
|
|
||||||
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
|
|
||||||
```
|
|
||||||
|
|
||||||
描述规则:
|
|
||||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
|
||||||
- 从 `schema.required` 区分核心/可选参数
|
|
||||||
- 按功能分类(移液、枪头、外设等)
|
|
||||||
- 标注 `placeholder_keys` 中的字段类型:
|
|
||||||
- `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点)
|
|
||||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
|
||||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
|
||||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
|
||||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
|
||||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
|
||||||
|
|
||||||
### Step 4 — 写 SKILL.md
|
|
||||||
|
|
||||||
直接复用 `unilab-device-api` 的 API 模板(10 个 endpoint),修改:
|
|
||||||
- 设备名称
|
|
||||||
- Action 数量
|
|
||||||
- 目录列表
|
|
||||||
- Session state 中的 `device_name`
|
|
||||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
|
||||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
|
||||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
|
||||||
|
|
||||||
API 模板结构:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 设备信息
|
|
||||||
- device_id, Python 源码路径, 设备类名
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
- ak/sk → AUTH, --addr → BASE URL
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
- lab_uuid(通过 API #1 自动匹配,不要问用户), device_name
|
|
||||||
|
|
||||||
## API Endpoints (10 个)
|
|
||||||
# 注意:
|
|
||||||
# - #1 获取 lab 列表 + 自动匹配 lab_uuid(遍历 is_admin 的 lab,
|
|
||||||
# 调用 /lab/info/{uuid} 比对 access_key == ak)
|
|
||||||
# - #2 创建工作流用 POST /lab/workflow
|
|
||||||
# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid}
|
|
||||||
|
|
||||||
## Placeholder Slot 填写规则
|
|
||||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
|
||||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
|
||||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
|
||||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
|
||||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
|
||||||
- 列出本设备所有 Slot 字段、类型及含义
|
|
||||||
|
|
||||||
## 渐进加载策略
|
|
||||||
## 完整工作流 Checklist
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5 — 验证
|
|
||||||
|
|
||||||
检查文件完整性:
|
|
||||||
- [ ] `SKILL.md` 包含 10 个 API endpoint
|
|
||||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
|
|
||||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
|
||||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
|
||||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
|
||||||
- [ ] 描述能让 agent 判断该用哪个 action
|
|
||||||
|
|
||||||
## Action JSON 文件结构
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "LiquidHandlerTransfer", // → API 的 action_type
|
|
||||||
"goal": { // goal 字段映射
|
|
||||||
"sources": "sources",
|
|
||||||
"targets": "targets",
|
|
||||||
"tip_racks": "tip_racks",
|
|
||||||
"asp_vols": "asp_vols"
|
|
||||||
},
|
|
||||||
"schema": { // ← 直接是 goal 的 schema(已提升)
|
|
||||||
"type": "object",
|
|
||||||
"properties": { // 参数定义(即请求中 goal 的字段)
|
|
||||||
"sources": { "type": "array", "items": { "type": "object" } },
|
|
||||||
"targets": { "type": "array", "items": { "type": "object" } },
|
|
||||||
"asp_vols": { "type": "array", "items": { "type": "number" } }
|
|
||||||
},
|
|
||||||
"required": [...],
|
|
||||||
"_unilabos_placeholder_info": { // ← Slot 类型标记
|
|
||||||
"sources": "unilabos_resources",
|
|
||||||
"targets": "unilabos_resources",
|
|
||||||
"tip_racks": "unilabos_resources"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"goal_default": { ... }, // 默认值
|
|
||||||
"placeholder_keys": { // ← 汇总所有 Slot 字段
|
|
||||||
"sources": "unilabos_resources", // ResourceSlot
|
|
||||||
"targets": "unilabos_resources",
|
|
||||||
"tip_racks": "unilabos_resources",
|
|
||||||
"target_device_id": "unilabos_devices" // DeviceSlot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
|
||||||
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段。
|
|
||||||
|
|
||||||
## Placeholder Slot 类型体系
|
|
||||||
|
|
||||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
|
|
||||||
|
|
||||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
|
||||||
|---------------|-----------|---------|---------|
|
|
||||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
|
||||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
|
||||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
|
||||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
|
||||||
|
|
||||||
### ResourceSlot(`unilabos_resources`)
|
|
||||||
|
|
||||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
|
||||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
|
||||||
- `id` 本身是从 parent 计算的路径格式
|
|
||||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
|
||||||
|
|
||||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
|
||||||
|
|
||||||
### DeviceSlot(`unilabos_devices`)
|
|
||||||
|
|
||||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
|
||||||
|
|
||||||
```
|
|
||||||
"/host_node"
|
|
||||||
"/bioyond_cell/reaction_station"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
|
||||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
|
||||||
|
|
||||||
### NodeSlot(`unilabos_nodes`)
|
|
||||||
|
|
||||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
|
||||||
|
|
||||||
```
|
|
||||||
"/PRCXI/PRCXI_Deck"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
|
||||||
|
|
||||||
### ClassSlot(`unilabos_class`)
|
|
||||||
|
|
||||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
|
||||||
|
|
||||||
```
|
|
||||||
"container"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通过 API #10 获取资源树
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
|
||||||
|
|
||||||
## 最终目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
./<skill-name>/
|
|
||||||
├── SKILL.md # API 端点 + 渐进加载指引
|
|
||||||
├── action-index.md # 动作索引:描述/用途/核心参数
|
|
||||||
└── actions/ # 每个 action 的完整 JSON Schema
|
|
||||||
├── action1.json
|
|
||||||
├── action2.json
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
从 req_device_registry_upload.json 中提取指定设备的 action schema。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
# 列出所有设备及 action 数量(自动搜索注册表文件)
|
|
||||||
python extract_device_actions.py
|
|
||||||
|
|
||||||
# 指定注册表文件路径
|
|
||||||
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
|
||||||
|
|
||||||
# 提取指定设备的 action 到目录
|
|
||||||
python extract_device_actions.py <device_id> <output_dir>
|
|
||||||
python extract_device_actions.py --registry <path> <device_id> <output_dir>
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
|
|
||||||
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
|
||||||
|
|
||||||
def find_registry(explicit_path=None):
|
|
||||||
"""
|
|
||||||
查找 req_device_registry_upload.json 文件。
|
|
||||||
|
|
||||||
搜索优先级:
|
|
||||||
1. 用户通过 --registry 显式指定的路径
|
|
||||||
2. <cwd>/unilabos_data/req_device_registry_upload.json
|
|
||||||
3. <cwd>/req_device_registry_upload.json
|
|
||||||
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
|
|
||||||
5. 向上逐级搜索父目录(最多 5 层)
|
|
||||||
"""
|
|
||||||
if explicit_path:
|
|
||||||
if os.path.isfile(explicit_path):
|
|
||||||
return explicit_path
|
|
||||||
if os.path.isdir(explicit_path):
|
|
||||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
|
||||||
if os.path.isfile(fp):
|
|
||||||
return fp
|
|
||||||
print(f"警告: 指定的路径不存在: {explicit_path}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
|
||||||
REGISTRY_FILENAME,
|
|
||||||
]
|
|
||||||
|
|
||||||
for c in candidates:
|
|
||||||
if os.path.isfile(c):
|
|
||||||
return c
|
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(workspace_root, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
|
|
||||||
cwd = os.getcwd()
|
|
||||||
for _ in range(5):
|
|
||||||
parent = os.path.dirname(cwd)
|
|
||||||
if parent == cwd:
|
|
||||||
break
|
|
||||||
cwd = parent
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(cwd, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def load_registry(path):
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
def list_devices(data):
|
|
||||||
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
|
|
||||||
resources = data.get('resources', [])
|
|
||||||
devices = []
|
|
||||||
for res in resources:
|
|
||||||
rid = res.get('id', '')
|
|
||||||
cls = res.get('class', {})
|
|
||||||
avm = cls.get('action_value_mappings', {})
|
|
||||||
module = cls.get('module', '')
|
|
||||||
if avm:
|
|
||||||
devices.append((rid, len(avm), module))
|
|
||||||
return devices
|
|
||||||
|
|
||||||
def flatten_schema_to_goal(action_data):
|
|
||||||
"""将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装"""
|
|
||||||
schema = action_data.get('schema', {})
|
|
||||||
goal_schema = schema.get('properties', {}).get('goal', {})
|
|
||||||
if goal_schema:
|
|
||||||
action_data = dict(action_data)
|
|
||||||
action_data['schema'] = goal_schema
|
|
||||||
return action_data
|
|
||||||
|
|
||||||
|
|
||||||
def extract_actions(data, device_id, output_dir):
|
|
||||||
"""提取指定设备的 action schema 到独立 JSON 文件"""
|
|
||||||
resources = data.get('resources', [])
|
|
||||||
for res in resources:
|
|
||||||
if res.get('id') == device_id:
|
|
||||||
cls = res.get('class', {})
|
|
||||||
module = cls.get('module', '')
|
|
||||||
avm = cls.get('action_value_mappings', {})
|
|
||||||
if not avm:
|
|
||||||
print(f"设备 {device_id} 没有 action_value_mappings")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if module:
|
|
||||||
py_path = module.split(":")[0].replace(".", "/") + ".py"
|
|
||||||
class_name = module.split(":")[-1] if ":" in module else ""
|
|
||||||
print(f"Python 源码: {py_path}")
|
|
||||||
if class_name:
|
|
||||||
print(f"设备类: {class_name}")
|
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
written = []
|
|
||||||
for action_name in sorted(avm.keys()):
|
|
||||||
action_data = flatten_schema_to_goal(avm[action_name])
|
|
||||||
filename = action_name.replace('-', '_') + '.json'
|
|
||||||
filepath = os.path.join(output_dir, filename)
|
|
||||||
with open(filepath, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(action_data, f, indent=2, ensure_ascii=False)
|
|
||||||
written.append(filename)
|
|
||||||
print(f" {filepath}")
|
|
||||||
return written
|
|
||||||
|
|
||||||
print(f"设备 {device_id} 未找到")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = sys.argv[1:]
|
|
||||||
explicit_registry = None
|
|
||||||
|
|
||||||
if "--registry" in args:
|
|
||||||
idx = args.index("--registry")
|
|
||||||
if idx + 1 < len(args):
|
|
||||||
explicit_registry = args[idx + 1]
|
|
||||||
args = args[:idx] + args[idx + 2:]
|
|
||||||
else:
|
|
||||||
print("错误: --registry 需要指定路径")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
registry_path = find_registry(explicit_registry)
|
|
||||||
if not registry_path:
|
|
||||||
print(f"错误: 找不到 {REGISTRY_FILENAME}")
|
|
||||||
print()
|
|
||||||
print("解决方法:")
|
|
||||||
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
|
|
||||||
print(" 2. 用 --registry 指定文件路径:")
|
|
||||||
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
|
|
||||||
print()
|
|
||||||
print("搜索过的路径:")
|
|
||||||
for p in [
|
|
||||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
|
||||||
REGISTRY_FILENAME,
|
|
||||||
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
|
|
||||||
]:
|
|
||||||
print(f" - {p}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"注册表: {registry_path}")
|
|
||||||
mtime = os.path.getmtime(registry_path)
|
|
||||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
|
|
||||||
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
|
|
||||||
data = load_registry(registry_path)
|
|
||||||
|
|
||||||
if len(args) == 0:
|
|
||||||
devices = list_devices(data)
|
|
||||||
print(f"\n找到 {len(devices)} 个设备:")
|
|
||||||
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
|
|
||||||
print("-" * 120)
|
|
||||||
for did, count, module in sorted(devices, key=lambda x: x[0]):
|
|
||||||
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
|
|
||||||
print(f"{did:<50} {count:>7} {py_path}")
|
|
||||||
|
|
||||||
elif len(args) == 2:
|
|
||||||
device_id = args[0]
|
|
||||||
output_dir = args[1]
|
|
||||||
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
|
|
||||||
written = extract_actions(data, device_id, output_dir)
|
|
||||||
if written:
|
|
||||||
print(f"\n共写入 {len(written)} 个 action 文件")
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("用法:")
|
|
||||||
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
|
|
||||||
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
从 ak/sk 生成 UniLab API Authorization header。
|
|
||||||
|
|
||||||
算法: base64(ak:sk) → "Authorization: Lab <token>"
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python gen_auth.py <ak> <sk>
|
|
||||||
python gen_auth.py --config <config.py>
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python gen_auth.py myak mysk
|
|
||||||
python gen_auth.py --config experiments/config.py
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def gen_auth(ak: str, sk: str) -> str:
|
|
||||||
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def extract_from_config(config_path: str) -> tuple:
|
|
||||||
"""从 config.py 中提取 ak 和 sk"""
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
|
|
||||||
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
|
|
||||||
if not ak_match or not sk_match:
|
|
||||||
return None, None
|
|
||||||
return ak_match.group(1), sk_match.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = sys.argv[1:]
|
|
||||||
|
|
||||||
if len(args) == 2 and args[0] == "--config":
|
|
||||||
ak, sk = extract_from_config(args[1])
|
|
||||||
if not ak or not sk:
|
|
||||||
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
|
|
||||||
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
|
|
||||||
sys.exit(1)
|
|
||||||
print(f"配置文件: {args[1]}")
|
|
||||||
elif len(args) == 2:
|
|
||||||
ak, sk = args
|
|
||||||
else:
|
|
||||||
print("用法:")
|
|
||||||
print(" python gen_auth.py <ak> <sk>")
|
|
||||||
print(" python gen_auth.py --config <config.py>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
token = gen_auth(ak, sk)
|
|
||||||
print(f"ak: {ak}")
|
|
||||||
print(f"sk: {sk}")
|
|
||||||
print()
|
|
||||||
print(f"Authorization header:")
|
|
||||||
print(f" Authorization: Lab {token}")
|
|
||||||
print()
|
|
||||||
print(f"curl 用法:")
|
|
||||||
print(f' curl -H "Authorization: Lab {token}" ...')
|
|
||||||
print()
|
|
||||||
print(f"Shell 变量:")
|
|
||||||
print(f' AUTH="Authorization: Lab {token}"')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
19
.github/dependabot.yml
vendored
19
.github/dependabot.yml
vendored
@@ -1,19 +0,0 @@
|
|||||||
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
67
.github/workflows/ci-check.yml
vendored
@@ -1,67 +0,0 @@
|
|||||||
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 (AST registry validation)
|
|
||||||
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
45
.github/workflows/conda-pack-build.yml
vendored
@@ -13,11 +13,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: 'win-64'
|
default: 'win-64'
|
||||||
type: string
|
type: string
|
||||||
build_full:
|
|
||||||
description: '是否构建完整版 unilabos-full (默认构建轻量版 unilabos)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-conda-pack:
|
build-conda-pack:
|
||||||
@@ -29,7 +24,7 @@ jobs:
|
|||||||
platform: linux-64
|
platform: linux-64
|
||||||
env_file: unilabos-linux-64.yaml
|
env_file: unilabos-linux-64.yaml
|
||||||
script_ext: sh
|
script_ext: sh
|
||||||
- os: macos-15 # Intel (via Rosetta)
|
- os: macos-13 # Intel
|
||||||
platform: osx-64
|
platform: osx-64
|
||||||
env_file: unilabos-osx-64.yaml
|
env_file: unilabos-osx-64.yaml
|
||||||
script_ext: sh
|
script_ext: sh
|
||||||
@@ -62,7 +57,7 @@ jobs:
|
|||||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.branch }}
|
ref: ${{ github.event.inputs.branch }}
|
||||||
@@ -74,7 +69,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
miniforge-version: latest
|
miniforge-version: latest
|
||||||
use-mamba: true
|
use-mamba: true
|
||||||
python-version: '3.11.14'
|
python-version: '3.11.11'
|
||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
channel-priority: flexible
|
channel-priority: flexible
|
||||||
activate-environment: unilab
|
activate-environment: unilab
|
||||||
@@ -86,14 +81,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo Installing unilabos and dependencies to unilab environment...
|
echo Installing unilabos and dependencies to unilab environment...
|
||||||
echo Using mamba for faster and more reliable dependency resolution...
|
echo Using mamba for faster and more reliable dependency resolution...
|
||||||
echo Build full: ${{ github.event.inputs.build_full }}
|
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
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)
|
- name: Install conda-pack, unilabos and dependencies (Unix)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
||||||
@@ -101,14 +89,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Installing unilabos and dependencies to unilab environment..."
|
echo "Installing unilabos and dependencies to unilab environment..."
|
||||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||||
echo "Build full: ${{ github.event.inputs.build_full }}"
|
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
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)
|
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||||
@@ -312,7 +293,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload distribution package
|
- name: Upload distribution package
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||||
path: dist-package/
|
path: dist-package/
|
||||||
@@ -327,12 +308,7 @@ jobs:
|
|||||||
echo ==========================================
|
echo ==========================================
|
||||||
echo Platform: ${{ matrix.platform }}
|
echo Platform: ${{ matrix.platform }}
|
||||||
echo Branch: ${{ github.event.inputs.branch }}
|
echo Branch: ${{ github.event.inputs.branch }}
|
||||||
echo Python version: 3.11.14
|
echo Python version: 3.11.11
|
||||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
|
||||||
echo Package: unilabos-full ^(complete^)
|
|
||||||
) else (
|
|
||||||
echo Package: unilabos ^(minimal^)
|
|
||||||
)
|
|
||||||
echo.
|
echo.
|
||||||
echo Distribution package contents:
|
echo Distribution package contents:
|
||||||
dir dist-package
|
dir dist-package
|
||||||
@@ -352,12 +328,7 @@ jobs:
|
|||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Platform: ${{ matrix.platform }}"
|
echo "Platform: ${{ matrix.platform }}"
|
||||||
echo "Branch: ${{ github.event.inputs.branch }}"
|
echo "Branch: ${{ github.event.inputs.branch }}"
|
||||||
echo "Python version: 3.11.14"
|
echo "Python version: 3.11.11"
|
||||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
|
||||||
echo "Package: unilabos-full (complete)"
|
|
||||||
else
|
|
||||||
echo "Package: unilabos (minimal)"
|
|
||||||
fi
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Distribution package contents:"
|
echo "Distribution package contents:"
|
||||||
ls -lh dist-package/
|
ls -lh dist-package/
|
||||||
|
|||||||
37
.github/workflows/deploy-docs.yml
vendored
37
.github/workflows/deploy-docs.yml
vendored
@@ -1,12 +1,10 @@
|
|||||||
name: Deploy Docs
|
name: Deploy Docs
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# 在 CI Check 成功后自动触发(仅 main 分支)
|
push:
|
||||||
workflow_run:
|
branches: [main]
|
||||||
workflows: ["CI Check"]
|
pull_request:
|
||||||
types: [completed]
|
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# 手动触发
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
branch:
|
branch:
|
||||||
@@ -35,19 +33,12 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
# Build documentation
|
# Build documentation
|
||||||
build:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# workflow_run 时使用触发工作流的分支,手动触发时使用输入的分支
|
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||||
ref: ${{ github.event.workflow_run.head_branch || github.event.inputs.branch || github.ref }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Miniforge (with mamba)
|
- name: Setup Miniforge (with mamba)
|
||||||
@@ -55,7 +46,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
miniforge-version: latest
|
miniforge-version: latest
|
||||||
use-mamba: true
|
use-mamba: true
|
||||||
python-version: '3.11.14'
|
python-version: '3.11.11'
|
||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
channel-priority: flexible
|
channel-priority: flexible
|
||||||
activate-environment: unilab
|
activate-environment: unilab
|
||||||
@@ -84,10 +75,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
id: pages
|
id: pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v4
|
||||||
if: |
|
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||||
github.event.workflow_run.head_branch == 'main' ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
|
||||||
|
|
||||||
- name: Build Sphinx documentation
|
- name: Build Sphinx documentation
|
||||||
run: |
|
run: |
|
||||||
@@ -105,18 +94,14 @@ jobs:
|
|||||||
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
|
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
|
||||||
|
|
||||||
- name: Upload build artifacts
|
- name: Upload build artifacts
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-pages-artifact@v3
|
||||||
if: |
|
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||||
github.event.workflow_run.head_branch == 'main' ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
|
||||||
with:
|
with:
|
||||||
path: docs/_build/html
|
path: docs/_build/html
|
||||||
|
|
||||||
# Deploy to GitHub Pages
|
# Deploy to GitHub Pages
|
||||||
deploy:
|
deploy:
|
||||||
if: |
|
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||||
github.event.workflow_run.head_branch == 'main' ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|||||||
48
.github/workflows/multi-platform-build.yml
vendored
48
.github/workflows/multi-platform-build.yml
vendored
@@ -1,16 +1,11 @@
|
|||||||
name: Multi-Platform Conda Build
|
name: Multi-Platform Conda Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# 在 CI Check 工作流完成后触发(仅限 main/dev 分支)
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["CI Check"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches: [main, dev]
|
|
||||||
# 支持 tag 推送(不依赖 CI Check)
|
|
||||||
push:
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
# 手动触发
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
platforms:
|
platforms:
|
||||||
@@ -22,37 +17,9 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
skip_ci_check:
|
|
||||||
description: '跳过等待 CI Check (手动触发时可选)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
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:
|
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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -60,7 +27,7 @@ jobs:
|
|||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
platform: linux-64
|
platform: linux-64
|
||||||
env_file: unilabos-linux-64.yaml
|
env_file: unilabos-linux-64.yaml
|
||||||
- os: macos-15 # Intel (via Rosetta)
|
- os: macos-13 # Intel
|
||||||
platform: osx-64
|
platform: osx-64
|
||||||
env_file: unilabos-osx-64.yaml
|
env_file: unilabos-osx-64.yaml
|
||||||
- os: macos-latest # ARM64
|
- os: macos-latest # ARM64
|
||||||
@@ -77,10 +44,8 @@ jobs:
|
|||||||
shell: bash -l {0}
|
shell: bash -l {0}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check if platform should be built
|
- name: Check if platform should be built
|
||||||
@@ -104,6 +69,7 @@ jobs:
|
|||||||
channels: conda-forge,robostack-staging,defaults
|
channels: conda-forge,robostack-staging,defaults
|
||||||
channel-priority: strict
|
channel-priority: strict
|
||||||
activate-environment: build-env
|
activate-environment: build-env
|
||||||
|
auto-activate-base: false
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
show-channel-urls: true
|
show-channel-urls: true
|
||||||
|
|
||||||
@@ -149,7 +115,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload conda package artifacts
|
- name: Upload conda package artifacts
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: conda-package-${{ matrix.platform }}
|
name: conda-package-${{ matrix.platform }}
|
||||||
path: conda-packages-temp
|
path: conda-packages-temp
|
||||||
|
|||||||
115
.github/workflows/unilabos-conda-build.yml
vendored
115
.github/workflows/unilabos-conda-build.yml
vendored
@@ -1,69 +1,32 @@
|
|||||||
name: UniLabOS Conda Build
|
name: UniLabOS Conda Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# 在 CI Check 成功后自动触发
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["CI Check"]
|
|
||||||
types: [completed]
|
|
||||||
branches: [main, dev]
|
|
||||||
# 标签推送时直接触发(发布版本)
|
|
||||||
push:
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
# 手动触发
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
platforms:
|
platforms:
|
||||||
description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64'
|
description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64'
|
||||||
required: false
|
required: false
|
||||||
default: 'linux-64'
|
default: 'linux-64'
|
||||||
build_full:
|
|
||||||
description: '是否构建 unilabos-full 完整包 (默认只构建 unilabos 基础包)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
upload_to_anaconda:
|
upload_to_anaconda:
|
||||||
description: '是否上传到Anaconda.org'
|
description: '是否上传到Anaconda.org'
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
skip_ci_check:
|
|
||||||
description: '跳过等待 CI Check (手动触发时可选)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
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:
|
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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
platform: linux-64
|
platform: linux-64
|
||||||
- os: macos-15 # Intel (via Rosetta)
|
- os: macos-13 # Intel
|
||||||
platform: osx-64
|
platform: osx-64
|
||||||
- os: macos-latest # ARM64
|
- os: macos-latest # ARM64
|
||||||
platform: osx-arm64
|
platform: osx-arm64
|
||||||
@@ -77,10 +40,8 @@ jobs:
|
|||||||
shell: bash -l {0}
|
shell: bash -l {0}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check if platform should be built
|
- name: Check if platform should be built
|
||||||
@@ -104,6 +65,7 @@ jobs:
|
|||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
channel-priority: strict
|
channel-priority: strict
|
||||||
activate-environment: build-env
|
activate-environment: build-env
|
||||||
|
auto-activate-base: false
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
show-channel-urls: true
|
show-channel-urls: true
|
||||||
|
|
||||||
@@ -119,61 +81,12 @@ jobs:
|
|||||||
conda list | grep -E "(rattler-build|anaconda-client)"
|
conda list | grep -E "(rattler-build|anaconda-client)"
|
||||||
echo "Platform: ${{ matrix.platform }}"
|
echo "Platform: ${{ matrix.platform }}"
|
||||||
echo "OS: ${{ matrix.os }}"
|
echo "OS: ${{ matrix.os }}"
|
||||||
echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}"
|
echo "Building UniLabOS package"
|
||||||
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 unilabos-env (conda environment only, noarch)
|
- name: Build conda package
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Building unilabos-env (conda environment dependencies)..."
|
rattler-build build -r .conda/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||||
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
|
- name: List built packages
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
@@ -195,9 +108,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload conda package artifacts
|
- name: Upload conda package artifacts
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: conda-package-unilabos-${{ matrix.platform }}
|
name: conda-package-unilabos-${{ matrix.platform }}
|
||||||
path: conda-packages-temp
|
path: conda-packages-temp
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 30
|
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
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,11 +1,8 @@
|
|||||||
cursor_docs/
|
|
||||||
configs/
|
configs/
|
||||||
temp/
|
temp/
|
||||||
output/
|
output/
|
||||||
unilabos_data/
|
unilabos_data/
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
.cursorignore
|
|
||||||
device_package*/
|
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
|
|||||||
87
AGENTS.md
87
AGENTS.md
@@ -1,87 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
Also follow the monorepo-level rules in `../AGENTS.md`.
|
|
||||||
|
|
||||||
## Build & Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install in editable mode (requires mamba env with python 3.11)
|
|
||||||
pip install -e .
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
|
|
||||||
# Run with a device graph
|
|
||||||
unilab --graph <graph.json> --config <config.py> --backend ros
|
|
||||||
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
|
||||||
|
|
||||||
# Common CLI flags
|
|
||||||
unilab --app_bridges websocket fastapi # communication bridges
|
|
||||||
unilab --test_mode # simulate hardware, no real execution
|
|
||||||
unilab --check_mode # CI validation of registry imports
|
|
||||||
unilab --skip_env_check # skip auto-install of dependencies
|
|
||||||
unilab --visual rviz|web|disable # visualization mode
|
|
||||||
unilab --is_slave # run as slave node
|
|
||||||
|
|
||||||
# Workflow upload subcommand
|
|
||||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
pytest tests/ # all tests
|
|
||||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
|
||||||
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Startup Flow
|
|
||||||
|
|
||||||
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
|
||||||
|
|
||||||
### Core Layers
|
|
||||||
|
|
||||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
|
|
||||||
|
|
||||||
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
|
|
||||||
|
|
||||||
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
|
|
||||||
|
|
||||||
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
|
|
||||||
|
|
||||||
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
|
|
||||||
|
|
||||||
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
|
|
||||||
|
|
||||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
|
|
||||||
|
|
||||||
### Configuration System
|
|
||||||
|
|
||||||
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
|
|
||||||
- Config files are `.py` files with matching class names (see `config/example_config.py`)
|
|
||||||
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
|
||||||
- Device topology defined in graph files (JSON with node-link format, or GraphML)
|
|
||||||
|
|
||||||
### Key Data Flow
|
|
||||||
|
|
||||||
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
|
||||||
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
|
||||||
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
|
||||||
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
|
||||||
|
|
||||||
### Test Data
|
|
||||||
|
|
||||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
|
||||||
|
|
||||||
## Code Conventions
|
|
||||||
|
|
||||||
- Code comments and log messages in simplified Chinese
|
|
||||||
- Python 3.11+, type hints expected
|
|
||||||
- Pydantic models for data validation (`resource_tracker.py`)
|
|
||||||
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
|
||||||
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
|
||||||
- CLI argument dashes auto-converted to underscores for consistency
|
|
||||||
|
|
||||||
## Licensing
|
|
||||||
|
|
||||||
- Framework code: GPL-3.0
|
|
||||||
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
recursive-include unilabos/test *
|
recursive-include unilabos/test *
|
||||||
recursive-include unilabos/utils *
|
|
||||||
recursive-include unilabos/registry *.yaml
|
recursive-include unilabos/registry *.yaml
|
||||||
recursive-include unilabos/app/web/static *
|
recursive-include unilabos/app/web/static *
|
||||||
recursive-include unilabos/app/web/templates *
|
recursive-include unilabos/app/web/templates *
|
||||||
|
|||||||
17
NOTICE
17
NOTICE
@@ -1,17 +0,0 @@
|
|||||||
# 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
90
README.md
@@ -8,13 +8,17 @@
|
|||||||
|
|
||||||
**English** | [中文](README_zh.md)
|
**English** | [中文](README_zh.md)
|
||||||
|
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
[](https://github.com/dptech-corp/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.
|
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
|
## Key Features
|
||||||
|
|
||||||
- Multi-device integration management
|
- Multi-device integration management
|
||||||
@@ -27,89 +31,41 @@ Uni-Lab-OS is a platform for laboratory automation, designed to connect and cont
|
|||||||
|
|
||||||
Detailed documentation can be found at:
|
Detailed documentation can be found at:
|
||||||
|
|
||||||
- [Online Documentation](https://deepmodeling.github.io/Uni-Lab-OS/)
|
- [Online Documentation](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Setup Conda Environment
|
Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system:
|
||||||
|
|
||||||
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
|
```bash
|
||||||
# Create new environment
|
# Create new environment
|
||||||
mamba create -n unilab python=3.11.14
|
mamba create -n unilab python=3.11.11
|
||||||
mamba activate unilab
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**When to use which?**
|
## Install Dev Uni-Lab-OS
|
||||||
- **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
|
```bash
|
||||||
# Clone the repository (only needed for development or examples)
|
# Clone the repository
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||||
cd Uni-Lab-OS
|
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://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
|
Please refer to [Documentation - Boot Examples](https://xuwznln.github.io/Uni-Lab-OS-Doc/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
|
## 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/deepmodeling/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/dptech-corp/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
|
## License
|
||||||
|
|
||||||
This project uses a dual licensing structure:
|
This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
- **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
|
## Project Statistics
|
||||||
|
|
||||||
@@ -121,4 +77,4 @@ See [NOTICE](NOTICE) for complete licensing details.
|
|||||||
|
|
||||||
## Contact Us
|
## Contact Us
|
||||||
|
|
||||||
- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
|
|||||||
90
README_zh.md
90
README_zh.md
@@ -8,13 +8,17 @@
|
|||||||
|
|
||||||
[English](README.md) | **中文**
|
[English](README.md) | **中文**
|
||||||
|
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
||||||
|
|
||||||
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
||||||
|
|
||||||
|
## 🏆 比赛
|
||||||
|
|
||||||
|
欢迎参加[有机化学合成智能实验大赛](https://bohrium.dp.tech/competitions/1451645258),使用 Uni-Lab-OS 探索自动化合成!
|
||||||
|
|
||||||
## 核心特点
|
## 核心特点
|
||||||
|
|
||||||
- 多设备集成管理
|
- 多设备集成管理
|
||||||
@@ -27,89 +31,43 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控
|
|||||||
|
|
||||||
详细文档可在以下位置找到:
|
详细文档可在以下位置找到:
|
||||||
|
|
||||||
- [在线文档](https://deepmodeling.github.io/Uni-Lab-OS/)
|
- [在线文档](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 配置 Conda 环境
|
1. 配置 Conda 环境
|
||||||
|
|
||||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的需求选择合适的安装包:
|
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含内容 |
|
|
||||||
|--------|----------|----------|
|
|
||||||
| `unilabos` | **推荐大多数用户** | 完整安装包,开箱即用 |
|
|
||||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
|
||||||
| `unilabos-full` | 仿真/可视化 | unilabos + ROS2 桌面版 + Gazebo + MoveIt |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建新环境
|
# 创建新环境
|
||||||
mamba create -n unilab python=3.11.14
|
mamba create -n unilab python=3.11.11
|
||||||
mamba activate unilab
|
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
|
```bash
|
||||||
# 克隆仓库(仅开发或查看示例时需要)
|
# 克隆仓库
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||||
cd Uni-Lab-OS
|
cd Uni-Lab-OS
|
||||||
|
|
||||||
|
# 安装 Uni-Lab-OS
|
||||||
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 启动 Uni-Lab 系统
|
3. 启动 Uni-Lab 系统:
|
||||||
|
|
||||||
请见[文档-启动样例](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
|
请见[文档-启动样例](https://xuwznln.github.io/Uni-Lab-OS-Doc/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/deepmodeling/Uni-Lab-OS/releases) 页面找到已构建的版本。
|
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/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)。
|
|
||||||
|
|
||||||
## 项目统计
|
## 项目统计
|
||||||
|
|
||||||
@@ -121,4 +79,4 @@ Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在
|
|||||||
|
|
||||||
## 联系我们
|
## 联系我们
|
||||||
|
|
||||||
- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ extensions = [
|
|||||||
"sphinx.ext.autodoc",
|
"sphinx.ext.autodoc",
|
||||||
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
|
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
|
||||||
"sphinx_rtd_theme",
|
"sphinx_rtd_theme",
|
||||||
"sphinxcontrib.mermaid",
|
"sphinxcontrib.mermaid"
|
||||||
]
|
]
|
||||||
|
|
||||||
source_suffix = {
|
source_suffix = {
|
||||||
@@ -58,7 +58,7 @@ html_theme = "sphinx_rtd_theme"
|
|||||||
|
|
||||||
# sphinx-book-theme 主题选项
|
# sphinx-book-theme 主题选项
|
||||||
html_theme_options = {
|
html_theme_options = {
|
||||||
"repository_url": "https://github.com/deepmodeling/Uni-Lab-OS",
|
"repository_url": "https://github.com/用户名/Uni-Lab",
|
||||||
"use_repository_button": True,
|
"use_repository_button": True,
|
||||||
"use_issues_button": True,
|
"use_issues_button": True,
|
||||||
"use_edit_page_button": True,
|
"use_edit_page_button": True,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,9 +15,6 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
|
|||||||
**示例:**
|
**示例:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.registry.decorators import device, topic_config
|
|
||||||
|
|
||||||
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
|
||||||
class MockGripper:
|
class MockGripper:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._position: float = 0.0
|
self._position: float = 0.0
|
||||||
@@ -26,23 +23,19 @@ class MockGripper:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 添加 @topic_config 才会定时广播
|
|
||||||
def position(self) -> float:
|
def position(self) -> float:
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def velocity(self) -> float:
|
def velocity(self) -> float:
|
||||||
return self._velocity
|
return self._velocity
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def torque(self) -> float:
|
def torque(self) -> float:
|
||||||
return self._torque
|
return self._torque
|
||||||
|
|
||||||
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||||
@property
|
@property
|
||||||
@topic_config(period=2.0) # 可自定义发布周期
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@@ -156,7 +149,7 @@ my_device: # 设备唯一标识符
|
|||||||
|
|
||||||
系统会自动分析您的 Python 驱动类并生成:
|
系统会自动分析您的 Python 驱动类并生成:
|
||||||
|
|
||||||
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||||
- `action_value_mappings`:从类方法自动生成动作映射
|
- `action_value_mappings`:从类方法自动生成动作映射
|
||||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||||
- `schema`:前端显示用的属性类型定义
|
- `schema`:前端显示用的属性类型定义
|
||||||
@@ -186,9 +179,7 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from unilabos.registry.decorators import device, topic_config
|
|
||||||
|
|
||||||
@device(id="my_device", category=["general"], description="My Device")
|
|
||||||
class MyDevice:
|
class MyDevice:
|
||||||
"""设备类文档字符串
|
"""设备类文档字符串
|
||||||
|
|
||||||
@@ -207,9 +198,8 @@ class MyDevice:
|
|||||||
# 初始化硬件连接
|
# 初始化硬件连接
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 必须添加 @topic_config 才会广播
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态(通过 @topic_config 广播)"""
|
"""设备状态(会自动广播)"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
def my_action(self, param: float) -> Dict[str, Any]:
|
def my_action(self, param: float) -> Dict[str, Any]:
|
||||||
@@ -227,61 +217,34 @@ class MyDevice:
|
|||||||
|
|
||||||
## 状态属性 vs 动作方法
|
## 状态属性 vs 动作方法
|
||||||
|
|
||||||
### 状态属性(@property + @topic_config)
|
### 状态属性(@property)
|
||||||
|
|
||||||
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
状态属性会被自动识别并定期广播:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 必须添加,否则不会广播
|
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
"""当前温度"""
|
"""当前温度"""
|
||||||
return self._read_temperature()
|
return self._read_temperature()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态: idle, running, error"""
|
"""设备状态: idle, running, error"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config(name="ready") # 可自定义发布名称
|
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
"""设备是否就绪"""
|
"""设备是否就绪"""
|
||||||
return self._status == "idle"
|
return self._status == "idle"
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以使用普通方法(非 @property)配合 `@topic_config`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@topic_config(period=10.0)
|
|
||||||
def get_sensor_data(self) -> Dict[str, float]:
|
|
||||||
"""获取传感器数据(get_ 前缀会自动去除,发布名为 sensor_data)"""
|
|
||||||
return {"temp": self._temp, "humidity": self._humidity}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`@topic_config` 参数**:
|
|
||||||
|
|
||||||
| 参数 | 类型 | 默认值 | 说明 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `period` | float | 5.0 | 发布周期(秒) |
|
|
||||||
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
|
|
||||||
| `qos` | int | 10 | QoS 深度 |
|
|
||||||
| `name` | str | None | 自定义发布名称 |
|
|
||||||
|
|
||||||
**发布名称优先级**:`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
|
|
||||||
|
|
||||||
**特点**:
|
**特点**:
|
||||||
|
|
||||||
- 必须使用 `@topic_config` 装饰器
|
- 使用`@property`装饰器
|
||||||
- 支持 `@property` 和普通方法
|
- 只读,不能有参数
|
||||||
- 添加到注册表的 `status_types`
|
- 自动添加到注册表的`status_types`
|
||||||
- 定期发布到 ROS2 topic
|
- 定期发布到 ROS2 topic
|
||||||
|
|
||||||
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
|
||||||
|
|
||||||
### 动作方法
|
### 动作方法
|
||||||
|
|
||||||
动作方法是设备可以执行的操作:
|
动作方法是设备可以执行的操作:
|
||||||
@@ -534,7 +497,6 @@ class LiquidHandler:
|
|||||||
self._status = "idle"
|
self._status = "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@@ -924,52 +886,7 @@ class MyDevice:
|
|||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|
||||||
### 1. 使用 `@device` 装饰器标识设备类
|
### 1. 类型注解
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device
|
|
||||||
|
|
||||||
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
|
|
||||||
class MyDevice:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
- `id`:设备唯一标识符,用于注册表匹配
|
|
||||||
- `category`:分类列表,前端用于分组显示
|
|
||||||
- `description`:设备描述
|
|
||||||
- `icon`:图标文件名(可选)
|
|
||||||
|
|
||||||
### 2. 使用 `@topic_config` 声明需要广播的状态
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
# ✓ @property + @topic_config → 会广播
|
|
||||||
@property
|
|
||||||
@topic_config(period=2.0)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self._temp
|
|
||||||
|
|
||||||
# ✓ 普通方法 + @topic_config → 会广播(get_ 前缀自动去除)
|
|
||||||
@topic_config(period=10.0)
|
|
||||||
def get_sensor_data(self) -> Dict[str, float]:
|
|
||||||
return {"temp": self._temp}
|
|
||||||
|
|
||||||
# ✓ 使用 name 参数自定义发布名称
|
|
||||||
@property
|
|
||||||
@topic_config(name="ready")
|
|
||||||
def is_ready(self) -> bool:
|
|
||||||
return self._status == "idle"
|
|
||||||
|
|
||||||
# ✗ 仅有 @property,没有 @topic_config → 不会广播
|
|
||||||
@property
|
|
||||||
def internal_state(self) -> str:
|
|
||||||
return self._state
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
|
|
||||||
|
|
||||||
### 3. 类型注解
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
@@ -984,7 +901,7 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 文档字符串
|
### 2. 文档字符串
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def method(self, param: float) -> Dict[str, Any]:
|
def method(self, param: float) -> Dict[str, Any]:
|
||||||
@@ -1006,7 +923,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 配置验证
|
### 3. 配置验证
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
@@ -1020,7 +937,7 @@ def __init__(self, config: Dict[str, Any]):
|
|||||||
self.baudrate = config['baudrate']
|
self.baudrate = config['baudrate']
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 资源清理
|
### 4. 资源清理
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
@@ -1029,7 +946,7 @@ def __del__(self):
|
|||||||
self.connection.close()
|
self.connection.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. 设计前端友好的返回值
|
### 5. 设计前端友好的返回值
|
||||||
|
|
||||||
**记住:返回值会直接显示在 Web 界面**
|
**记住:返回值会直接显示在 Web 界面**
|
||||||
|
|
||||||
|
|||||||
@@ -422,20 +422,18 @@ placeholder_keys:
|
|||||||
|
|
||||||
### status_types
|
### status_types
|
||||||
|
|
||||||
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
status_types:
|
status_types:
|
||||||
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
||||||
is_heating: bool
|
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
||||||
status: str
|
status: str # 从 get_status() 或 @property status
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意事项**:
|
**注意事项**:
|
||||||
|
|
||||||
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
||||||
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
|
||||||
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
|
||||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||||
|
|
||||||
@@ -539,13 +537,11 @@ class AdvancedLiquidHandler:
|
|||||||
self._temperature = 25.0
|
self._temperature = 25.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态"""
|
"""设备状态"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
"""当前温度"""
|
"""当前温度"""
|
||||||
return self._temperature
|
return self._temperature
|
||||||
@@ -813,23 +809,21 @@ my_temperature_controller:
|
|||||||
你的设备类需要符合以下要求:
|
你的设备类需要符合以下要求:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.registry.decorators import device, topic_config
|
from unilabos.common.device_base import DeviceBase
|
||||||
|
|
||||||
@device(id="my_device", category=["temperature"], description="My Device")
|
class MyDevice(DeviceBase):
|
||||||
class MyDevice:
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||||
|
super().__init__(config)
|
||||||
self.port = config.get('port', '/dev/ttyUSB0')
|
self.port = config.get('port', '/dev/ttyUSB0')
|
||||||
|
|
||||||
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
# 状态方法(会自动生成到 status_types)
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self):
|
def status(self):
|
||||||
"""返回设备状态"""
|
"""返回设备状态"""
|
||||||
return "idle"
|
return "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def temperature(self):
|
def temperature(self):
|
||||||
"""返回当前温度"""
|
"""返回当前温度"""
|
||||||
return 25.0
|
return 25.0
|
||||||
@@ -1045,34 +1039,7 @@ resource.type # "resource"
|
|||||||
|
|
||||||
### 代码规范
|
### 代码规范
|
||||||
|
|
||||||
1. **使用 `@device` 装饰器标识设备类**
|
1. **始终使用类型注解**
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device
|
|
||||||
|
|
||||||
@device(id="my_device", category=["heating"], description="My Device")
|
|
||||||
class MyDevice:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **使用 `@topic_config` 声明广播属性**
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
# ✓ 需要广播的状态属性
|
|
||||||
@property
|
|
||||||
@topic_config(period=2.0)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self._temp
|
|
||||||
|
|
||||||
# ✗ 仅有 @property 不会广播
|
|
||||||
@property
|
|
||||||
def internal_counter(self) -> int:
|
|
||||||
return self._counter
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **始终使用类型注解**
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✓ 好
|
# ✓ 好
|
||||||
@@ -1084,7 +1051,7 @@ def method(self, resource, device):
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **提供有意义的参数名**
|
2. **提供有意义的参数名**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✓ 好 - 清晰的参数名
|
# ✓ 好 - 清晰的参数名
|
||||||
@@ -1096,7 +1063,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **使用 Optional 表示可选参数**
|
3. **使用 Optional 表示可选参数**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -1109,7 +1076,7 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **添加详细的文档字符串**
|
4. **添加详细的文档字符串**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def method(
|
def method(
|
||||||
@@ -1129,13 +1096,13 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **方法命名规范**
|
5. **方法命名规范**
|
||||||
|
|
||||||
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
||||||
- 动作方法使用动词开头
|
- 动作方法使用动词开头
|
||||||
- 保持命名清晰、一致
|
- 保持命名清晰、一致
|
||||||
|
|
||||||
8. **完善的错误处理**
|
6. **完善的错误处理**
|
||||||
- 实现完善的错误处理
|
- 实现完善的错误处理
|
||||||
- 添加日志记录
|
- 添加日志记录
|
||||||
- 提供有意义的错误信息
|
- 提供有意义的错误信息
|
||||||
|
|||||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 实验室A
|
# 实验室A
|
||||||
unilab --ak your_ak --sk your_sk --upload_registry
|
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||||
|
|
||||||
# 实验室B
|
# 实验室B
|
||||||
unilab --ak your_ak --sk your_sk --upload_registry
|
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,7 +12,3 @@ sphinx-copybutton>=0.5.0
|
|||||||
|
|
||||||
# 用于自动摘要生成
|
# 用于自动摘要生成
|
||||||
sphinx-autobuild>=2024.2.4
|
sphinx-autobuild>=2024.2.4
|
||||||
|
|
||||||
# 用于PDF导出 (rinohtype方案,纯Python无需LaTeX)
|
|
||||||
rinohtype>=0.5.4
|
|
||||||
sphinx-simplepdf>=1.6.0
|
|
||||||
@@ -31,14 +31,6 @@
|
|||||||
|
|
||||||
详细的安装步骤请参考 [安装指南](installation.md)。
|
详细的安装步骤请参考 [安装指南](installation.md)。
|
||||||
|
|
||||||
**选择合适的安装包:**
|
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含组件 |
|
|
||||||
|--------|----------|----------|
|
|
||||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
|
||||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
|
||||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
|
||||||
|
|
||||||
**关键步骤:**
|
**关键步骤:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -46,30 +38,15 @@
|
|||||||
# 下载 Miniforge: https://github.com/conda-forge/miniforge/releases
|
# 下载 Miniforge: https://github.com/conda-forge/miniforge/releases
|
||||||
|
|
||||||
# 2. 创建 Conda 环境
|
# 2. 创建 Conda 环境
|
||||||
mamba create -n unilab python=3.11.14
|
mamba create -n unilab python=3.11.11
|
||||||
|
|
||||||
# 3. 激活环境
|
# 3. 激活环境
|
||||||
mamba activate unilab
|
mamba activate unilab
|
||||||
|
|
||||||
# 4. 安装 Uni-Lab-OS(选择其一)
|
# 4. 安装 Uni-Lab-OS
|
||||||
|
|
||||||
# 方案 A:标准安装(推荐大多数用户)
|
|
||||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
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 验证安装
|
#### 1.2 验证安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -439,9 +416,6 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
1. 访问 Web 界面,进入"仪器耗材"模块
|
1. 访问 Web 界面,进入"仪器耗材"模块
|
||||||
2. 在"仪器设备"区域找到并添加上述设备
|
2. 在"仪器设备"区域找到并添加上述设备
|
||||||
3. 在"物料耗材"区域找到并添加容器
|
3. 在"物料耗材"区域找到并添加容器
|
||||||
4. 在workstation中配置protocol_type包含PumpTransferProtocol
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|
||||||
@@ -795,43 +768,7 @@ Waiting for host service...
|
|||||||
|
|
||||||
详细的设备驱动编写指南请参考 [添加设备驱动](../developer_guide/add_device.md)。
|
详细的设备驱动编写指南请参考 [添加设备驱动](../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 内置了常见设备,但您的实验室可能有特殊设备需要集成:
|
Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要集成:
|
||||||
|
|
||||||
@@ -840,7 +777,7 @@ Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要
|
|||||||
- 特殊的实验流程
|
- 特殊的实验流程
|
||||||
- 第三方设备集成
|
- 第三方设备集成
|
||||||
|
|
||||||
#### 9.3 创建 Python 包
|
#### 9.2 创建 Python 包
|
||||||
|
|
||||||
为了方便开发和管理,建议为您的实验室创建独立的 Python 包。
|
为了方便开发和管理,建议为您的实验室创建独立的 Python 包。
|
||||||
|
|
||||||
@@ -877,7 +814,7 @@ touch my_lab_devices/my_lab_devices/__init__.py
|
|||||||
touch my_lab_devices/my_lab_devices/devices/__init__.py
|
touch my_lab_devices/my_lab_devices/devices/__init__.py
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 9.4 创建 setup.py
|
#### 9.3 创建 setup.py
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# my_lab_devices/setup.py
|
# my_lab_devices/setup.py
|
||||||
@@ -908,7 +845,7 @@ setup(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 9.5 开发安装
|
#### 9.4 开发安装
|
||||||
|
|
||||||
使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效:
|
使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效:
|
||||||
|
|
||||||
@@ -923,7 +860,7 @@ pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
|||||||
- 方便调试和测试
|
- 方便调试和测试
|
||||||
- 支持版本控制(git)
|
- 支持版本控制(git)
|
||||||
|
|
||||||
#### 9.6 编写设备驱动
|
#### 9.5 编写设备驱动
|
||||||
|
|
||||||
创建设备驱动文件:
|
创建设备驱动文件:
|
||||||
|
|
||||||
@@ -1064,7 +1001,7 @@ class MyPump:
|
|||||||
- **返回 Dict**:所有动作方法返回字典类型
|
- **返回 Dict**:所有动作方法返回字典类型
|
||||||
- **文档字符串**:详细说明参数和功能
|
- **文档字符串**:详细说明参数和功能
|
||||||
|
|
||||||
#### 9.7 测试设备驱动
|
#### 9.6 测试设备驱动
|
||||||
|
|
||||||
创建简单的测试脚本:
|
创建简单的测试脚本:
|
||||||
|
|
||||||
@@ -1870,7 +1807,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
|
|
||||||
#### 14.5 社区支持
|
#### 14.5 社区支持
|
||||||
|
|
||||||
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- **GitHub Issues**:[https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法
|
|||||||
### 使用示例
|
### 使用示例
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.resources.resource_tracker import ResourceDictInstance
|
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||||
|
|
||||||
# 旧格式节点
|
# 旧格式节点
|
||||||
old_format_node = {
|
old_format_node = {
|
||||||
@@ -477,10 +477,10 @@ old_format_node = {
|
|||||||
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
|
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
|
||||||
|
|
||||||
# 访问标准化后的数据
|
# 访问标准化后的数据
|
||||||
print(instance.res_content.id) # "pump_1"
|
print(instance.res_content.id) # "pump_1"
|
||||||
print(instance.res_content.uuid) # 自动生成的 UUID
|
print(instance.res_content.uuid) # 自动生成的 UUID
|
||||||
print(instance.res_content.config) # {}
|
print(instance.res_content.config) # {}
|
||||||
print(instance.res_content.data) # {}
|
print(instance.res_content.data) # {}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 格式迁移建议
|
### 格式迁移建议
|
||||||
@@ -857,4 +857,4 @@ class ResourceDictPosition(BaseModel):
|
|||||||
- 在 Web 界面中使用模板创建
|
- 在 Web 界面中使用模板创建
|
||||||
- 参考示例文件:`test/experiments/` 目录
|
- 参考示例文件:`test/experiments/` 目录
|
||||||
- 查看 ResourceDict 源码了解完整定义
|
- 查看 ResourceDict 源码了解完整定义
|
||||||
- [GitHub 讨论区](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 275 KiB |
@@ -13,26 +13,15 @@
|
|||||||
- 开发者需要 Git 和基本的 Python 开发知识
|
- 开发者需要 Git 和基本的 Python 开发知识
|
||||||
- 自定义 msgs 需要 GitHub 账号
|
- 自定义 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 分钟 (网络良好的情况下) |
|
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||||
| **方式二:手动安装** | **大多数用户** | `unilabos` | 完整功能,开箱即用 | 10-20 分钟 |
|
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
|
||||||
| **方式三:开发者安装** | 开发者、需要修改源码 | `unilabos-env` | 可编辑模式,支持自定义开发 | 20-30 分钟 |
|
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
|
||||||
| **仿真/可视化** | 仿真测试、可视化调试 | `unilabos-full` | 含 Gazebo、rviz2、MoveIt | 30-60 分钟 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,7 +37,7 @@ Uni-Lab-OS 提供三个安装包版本,根据您的需求选择:
|
|||||||
|
|
||||||
#### 第一步:下载预打包环境
|
#### 第一步:下载预打包环境
|
||||||
|
|
||||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||||
|
|
||||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||||
|
|
||||||
@@ -155,38 +144,17 @@ bash Miniforge3-$(uname)-$(uname -m).sh
|
|||||||
使用以下命令创建 Uni-Lab 专用环境:
|
使用以下命令创建 Uni-Lab 专用环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mamba create -n unilab python=3.11.14 # 目前ros2组件依赖版本大多为3.11.14
|
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
|
||||||
mamba activate unilab
|
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" 的环境
|
- `-n unilab`: 创建名为 "unilab" 的环境
|
||||||
- `uni-lab::unilabos`: 安装 unilabos 完整包,开箱即用(推荐)
|
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
|
||||||
- `uni-lab::unilabos-env`: 仅安装环境依赖,适合开发者使用 `pip install -e .`
|
|
||||||
- `uni-lab::unilabos-full`: 安装完整包(含 ROS2 Desktop、Gazebo、MoveIt 等)
|
|
||||||
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
- `-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
|
```bash
|
||||||
@@ -195,14 +163,8 @@ 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/pkgs/free/
|
||||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
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 -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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第三步:激活环境
|
### 第三步:激活环境
|
||||||
@@ -227,13 +189,13 @@ conda activate unilab
|
|||||||
### 第一步:克隆仓库
|
### 第一步:克隆仓库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||||
cd Uni-Lab-OS
|
cd Uni-Lab-OS
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您需要贡献代码,建议先 Fork 仓库:
|
如果您需要贡献代码,建议先 Fork 仓库:
|
||||||
|
|
||||||
1. 访问 https://github.com/deepmodeling/Uni-Lab-OS
|
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
|
||||||
2. 点击右上角的 "Fork" 按钮
|
2. 点击右上角的 "Fork" 按钮
|
||||||
3. Clone 您的 Fork 版本:
|
3. Clone 您的 Fork 版本:
|
||||||
```bash
|
```bash
|
||||||
@@ -241,87 +203,58 @@ cd Uni-Lab-OS
|
|||||||
cd Uni-Lab-OS
|
cd Uni-Lab-OS
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第二步:安装开发环境(unilabos-env)
|
### 第二步:安装基础环境
|
||||||
|
|
||||||
**重要**:开发者请使用 `unilabos-env` 包,它专为开发者设计:
|
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。
|
||||||
- 包含 ROS2 核心组件和消息包(ros-humble-ros-core、std-msgs、geometry-msgs 等)
|
|
||||||
- 包含 transforms3d、cv-bridge、tf2 等 conda 依赖
|
#### 选项 A:通过一键安装(推荐)
|
||||||
- 包含 `uv` 工具,用于快速安装 pip 依赖
|
|
||||||
- **不包含** pip 依赖和 unilabos 包(由 `pip install -e .` 和 `uv pip install` 安装)
|
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建并激活环境
|
|
||||||
mamba create -n unilab python=3.11.14
|
|
||||||
conda activate unilab
|
conda activate unilab
|
||||||
|
|
||||||
# 安装开发者环境包(ROS2 + conda 依赖 + uv)
|
|
||||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第三步:安装 pip 依赖和可编辑模式安装
|
#### 选项 B:通过手动安装
|
||||||
|
|
||||||
克隆代码并安装依赖:
|
参考上文"方式二:手动安装",创建并安装环境:
|
||||||
|
|
||||||
|
```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
|
```bash
|
||||||
# 确保环境已激活
|
# 确保环境已激活
|
||||||
conda activate unilab
|
conda activate unilab
|
||||||
|
|
||||||
# 克隆仓库(如果还未克隆)
|
# 卸载 pip 安装的 unilabos(保留所有 conda 依赖)
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
pip uninstall unilabos -y
|
||||||
cd Uni-Lab-OS
|
|
||||||
|
|
||||||
# 切换到 dev 分支(可选)
|
# 克隆 dev 分支(如果还未克隆)
|
||||||
|
cd /path/to/your/workspace
|
||||||
|
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||||
|
# 或者如果已经克隆,切换到 dev 分支
|
||||||
|
cd Uni-Lab-OS
|
||||||
git checkout dev
|
git checkout dev
|
||||||
git pull
|
git pull
|
||||||
```
|
|
||||||
|
|
||||||
**推荐:使用安装脚本**(自动检测中文环境,使用 uv 加速):
|
# 以可编辑模式安装开发版 unilabos
|
||||||
|
|
||||||
```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
|
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(可编辑模式),代码修改立即生效,无需重新安装
|
||||||
|
- `-i`: 使用清华镜像源加速下载
|
||||||
- `-e` (editable mode):代码修改**立即生效**,无需重新安装
|
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
|
||||||
- 适合开发调试:修改代码后直接运行测试
|
|
||||||
- 与 `unilabos-env` 配合:环境依赖由 conda 管理,unilabos 代码由 pip 管理
|
|
||||||
|
|
||||||
**验证安装**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查 unilabos 版本
|
|
||||||
python -c "import unilabos; print(unilabos.__version__)"
|
|
||||||
|
|
||||||
# 检查安装位置(应该指向你的代码目录)
|
|
||||||
pip show unilabos | grep Location
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
||||||
|
|
||||||
@@ -531,45 +464,7 @@ cd $CONDA_PREFIX/envs/unilab
|
|||||||
|
|
||||||
### 问题 8: 环境很大,有办法减小吗?
|
### 问题 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: 如何更新到最新版本?
|
### 问题 9: 如何更新到最新版本?
|
||||||
|
|
||||||
@@ -608,15 +503,14 @@ mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-f
|
|||||||
## 需要帮助?
|
## 需要帮助?
|
||||||
|
|
||||||
- **故障排查**: 查看更详细的故障排查信息
|
- **故障排查**: 查看更详细的故障排查信息
|
||||||
- **GitHub Issues**: [报告问题](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
- **开发者文档**: 查看开发者指南获取更多技术细节
|
- **开发者文档**: 查看开发者指南获取更多技术细节
|
||||||
- **社区讨论**: [GitHub Discussions](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**提示**:
|
**提示**:
|
||||||
|
|
||||||
- **大多数用户**推荐使用方式二(手动安装)的 `unilabos` 标准版
|
- 生产环境推荐使用方式二(手动安装)的稳定版本
|
||||||
- **开发者**推荐使用方式三(开发者安装),安装 `unilabos-env` 后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖
|
- 开发和测试推荐使用方式三(开发者安装)
|
||||||
- **仿真/可视化**推荐安装 `unilabos-full` 完整版
|
- 快速体验和演示推荐使用方式一(一键安装)
|
||||||
- **快速体验和演示**推荐使用方式一(一键安装)
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ options:
|
|||||||
--is_slave Run the backend as slave node (without host privileges).
|
--is_slave Run the backend as slave node (without host privileges).
|
||||||
--slave_no_host Skip waiting for host service in slave mode
|
--slave_no_host Skip waiting for host service in slave mode
|
||||||
--upload_registry Upload registry information when starting unilab
|
--upload_registry Upload registry information when starting unilab
|
||||||
|
--use_remote_resource Use remote resources when starting unilab
|
||||||
--config CONFIG Configuration file path, supports .py format Python config files
|
--config CONFIG Configuration file path, supports .py format Python config files
|
||||||
--port PORT Port for web service information page
|
--port PORT Port for web service information page
|
||||||
--disable_browser Disable opening information page on startup
|
--disable_browser Disable opening information page on startup
|
||||||
@@ -84,7 +85,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
支持两种方式:
|
支持两种方式:
|
||||||
|
|
||||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||||
- **远程资源**:不指定本地文件即可
|
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||||
|
|
||||||
### 7. 注册表构建
|
### 7. 注册表构建
|
||||||
|
|
||||||
@@ -195,7 +196,7 @@ unilab --config path/to/your/config.py
|
|||||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||||
|
|
||||||
# 使用远程资源启动
|
# 使用远程资源启动
|
||||||
unilab --ak your_ak --sk your_sk
|
unilab --ak your_ak --sk your_sk --use_remote_resource
|
||||||
|
|
||||||
# 更新注册表
|
# 更新注册表
|
||||||
unilab --ak your_ak --sk your_sk --complete_registry
|
unilab --ak your_ak --sk your_sk --complete_registry
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.19
|
version: 0.10.13
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
@@ -17,7 +17,7 @@ build:
|
|||||||
- bash $SRC_DIR/build_ament_cmake.sh
|
- bash $SRC_DIR/build_ament_cmake.sh
|
||||||
|
|
||||||
about:
|
about:
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS."
|
description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS."
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ requirements:
|
|||||||
build:
|
build:
|
||||||
- ${{ compiler('cxx') }}
|
- ${{ compiler('cxx') }}
|
||||||
- ${{ compiler('c') }}
|
- ${{ compiler('c') }}
|
||||||
- python ==3.11.14
|
- python ==3.11.11
|
||||||
- numpy
|
- numpy
|
||||||
- if: build_platform != target_platform
|
- if: build_platform != target_platform
|
||||||
then:
|
then:
|
||||||
@@ -63,14 +63,14 @@ requirements:
|
|||||||
- robostack-staging::ros-humble-rosidl-default-generators
|
- robostack-staging::ros-humble-rosidl-default-generators
|
||||||
- robostack-staging::ros-humble-std-msgs
|
- robostack-staging::ros-humble-std-msgs
|
||||||
- robostack-staging::ros-humble-geometry-msgs
|
- robostack-staging::ros-humble-geometry-msgs
|
||||||
- robostack-staging::ros2-distro-mutex=0.7
|
- robostack-staging::ros2-distro-mutex=0.6
|
||||||
run:
|
run:
|
||||||
- robostack-staging::ros-humble-action-msgs
|
- robostack-staging::ros-humble-action-msgs
|
||||||
- robostack-staging::ros-humble-ros-workspace
|
- robostack-staging::ros-humble-ros-workspace
|
||||||
- robostack-staging::ros-humble-rosidl-default-runtime
|
- robostack-staging::ros-humble-rosidl-default-runtime
|
||||||
- robostack-staging::ros-humble-std-msgs
|
- robostack-staging::ros-humble-std-msgs
|
||||||
- robostack-staging::ros-humble-geometry-msgs
|
- robostack-staging::ros-humble-geometry-msgs
|
||||||
- robostack-staging::ros2-distro-mutex=0.7
|
- robostack-staging::ros2-distro-mutex=0.6
|
||||||
- if: osx and x86_64
|
- if: osx and x86_64
|
||||||
then:
|
then:
|
||||||
- __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
- __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.19"
|
version: "0.10.13"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Verification:
|
|||||||
-------------
|
-------------
|
||||||
|
|
||||||
The verify_installation.py script will check:
|
The verify_installation.py script will check:
|
||||||
- Python version (3.11.14)
|
- Python version (3.11.11)
|
||||||
- ROS2 rclpy installation
|
- ROS2 rclpy installation
|
||||||
- UniLabOS installation and dependencies
|
- UniLabOS installation and dependencies
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ Build Information:
|
|||||||
|
|
||||||
Branch: {branch}
|
Branch: {branch}
|
||||||
Platform: {platform}
|
Platform: {platform}
|
||||||
Python: 3.11.14
|
Python: 3.11.11
|
||||||
Date: {build_date}
|
Date: {build_date}
|
||||||
|
|
||||||
Troubleshooting:
|
Troubleshooting:
|
||||||
@@ -126,7 +126,7 @@ If installation fails:
|
|||||||
For more help:
|
For more help:
|
||||||
- Documentation: docs/user_guide/installation.md
|
- Documentation: docs/user_guide/installation.md
|
||||||
- Quick Start: QUICK_START_CONDA_PACK.md
|
- Quick Start: QUICK_START_CONDA_PACK.md
|
||||||
- Issues: https://github.com/deepmodeling/Uni-Lab-OS/issues
|
- Issues: https://github.com/dptech-corp/Uni-Lab-OS/issues
|
||||||
|
|
||||||
License:
|
License:
|
||||||
--------
|
--------
|
||||||
@@ -134,7 +134,7 @@ License:
|
|||||||
UniLabOS is licensed under GPL-3.0-only.
|
UniLabOS is licensed under GPL-3.0-only.
|
||||||
See LICENSE file for details.
|
See LICENSE file for details.
|
||||||
|
|
||||||
Repository: https://github.com/deepmodeling/Uni-Lab-OS
|
Repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return readme
|
return readme
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
#!/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
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.19',
|
version='0.10.13',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
15
tests/devices/liquid_handling/README.md
Normal file
15
tests/devices/liquid_handling/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Liquid handling 集成测试
|
||||||
|
|
||||||
|
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
||||||
|
|
||||||
|
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
||||||
|
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
||||||
|
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export UNILAB_SIM_TEST=1
|
||||||
|
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
||||||
|
```
|
||||||
|
|
||||||
|
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
||||||
|
|
||||||
@@ -1,505 +1,337 @@
|
|||||||
|
"""
|
||||||
|
PRCXI transfer_liquid 集成测试。
|
||||||
|
|
||||||
|
这些用例会启动 UniLiquidHandler RViz 仿真 backend,需要同时满足:
|
||||||
|
1. 安装 pylabrobot 依赖;
|
||||||
|
2. 设置环境变量 UNILAB_SIM_TEST=1;
|
||||||
|
3. 具备 ROS 运行环境(rviz_backend 会创建 ROS 节点)。
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
from typing import List, Sequence
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||||
|
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Deck, PRCXI9300Trash
|
||||||
|
from unilabos.devices.liquid_handling.prcxi.prcxi_labware import (
|
||||||
|
PRCXI_300ul_Tips,
|
||||||
|
PRCXI_BioER_96_wellplate,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.slow
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pylabrobot.resources import Coordinate, Deck, Plate, TipRack, Well
|
||||||
|
except ImportError: # pragma: no cover - 测试环境缺少 pylabrobot 时直接跳过
|
||||||
|
Coordinate = Deck = Plate = TipRack = Well = None # type: ignore[assignment]
|
||||||
|
PYLABROBOT_AVAILABLE = False
|
||||||
|
else:
|
||||||
|
PYLABROBOT_AVAILABLE = True
|
||||||
|
|
||||||
|
SIM_ENV_VAR = "UNILAB_SIM_TEST"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass
|
||||||
class DummyContainer:
|
class SimulationContext:
|
||||||
name: str
|
handler: LiquidHandlerAbstract
|
||||||
|
deck: Deck
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
tip_rack: TipRack
|
||||||
return f"DummyContainer({self.name})"
|
source_plate: Plate
|
||||||
|
target_plate: Plate
|
||||||
|
waste_plate: Plate
|
||||||
@dataclass(frozen=True)
|
channel_num: int
|
||||||
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):
|
def run(coro):
|
||||||
return asyncio.run(coro)
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_single_channel_basic_calls():
|
def _ensure_unilabos_extra(well: Well) -> None:
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
if not hasattr(well, "unilabos_extra") or well.unilabos_extra is None:
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
well.unilabos_extra = {} # type: ignore[attr-defined]
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
|
||||||
|
|
||||||
run(
|
def _assign_sample_uuid(well: Well, value: str) -> None:
|
||||||
lh.transfer_liquid(
|
_ensure_unilabos_extra(well)
|
||||||
|
well.unilabos_extra["sample_uuid"] = value # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def _zero_coordinate() -> Coordinate:
|
||||||
|
if hasattr(Coordinate, "zero"):
|
||||||
|
return Coordinate.zero()
|
||||||
|
return Coordinate(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _zero_offsets(count: int) -> List[Coordinate]:
|
||||||
|
return [_zero_coordinate() for _ in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_simulation_deck() -> tuple[PRCXI9300Deck, TipRack, Plate, Plate, Plate, PRCXI9300Trash]:
|
||||||
|
deck = PRCXI9300Deck(name="PRCXI_Deck", size_x=542, size_y=374, size_z=50)
|
||||||
|
tip_rack = PRCXI_300ul_Tips("Tips")
|
||||||
|
source_plate = PRCXI_BioER_96_wellplate("SourcePlate")
|
||||||
|
target_plate = PRCXI_BioER_96_wellplate("TargetPlate")
|
||||||
|
waste_plate = PRCXI_BioER_96_wellplate("WastePlate")
|
||||||
|
trash = PRCXI9300Trash(name="trash", size_x=100, size_y=100, size_z=50)
|
||||||
|
deck.assign_child_resource(tip_rack, location=Coordinate(0, 0, 0))
|
||||||
|
deck.assign_child_resource(source_plate, location=Coordinate(150, 0, 0))
|
||||||
|
deck.assign_child_resource(target_plate, location=Coordinate(300, 0, 0))
|
||||||
|
deck.assign_child_resource(waste_plate, location=Coordinate(450, 0, 0))
|
||||||
|
deck.assign_child_resource(trash, location=Coordinate(150, -120, 0))
|
||||||
|
return deck, tip_rack, source_plate, target_plate, waste_plate, trash
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_backend(handler: LiquidHandlerAbstract) -> None:
|
||||||
|
try:
|
||||||
|
run(handler.backend.stop())
|
||||||
|
except Exception: # pragma: no cover - 如果 backend 已经停止
|
||||||
|
pass
|
||||||
|
simulate_handler = getattr(handler, "_simulate_handler", None)
|
||||||
|
if simulate_handler is not None and getattr(simulate_handler, "backend", None) is not None:
|
||||||
|
try:
|
||||||
|
run(simulate_handler.backend.stop())
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=[1, 8])
|
||||||
|
def prcxi_simulation(request) -> SimulationContext:
|
||||||
|
if not PYLABROBOT_AVAILABLE:
|
||||||
|
pytest.skip("pylabrobot is required for PRCXI simulation tests.")
|
||||||
|
if os.environ.get(SIM_ENV_VAR) != "1":
|
||||||
|
pytest.skip(f"Set {SIM_ENV_VAR}=1 to run PRCXI simulation tests.")
|
||||||
|
|
||||||
|
channel_num = request.param
|
||||||
|
deck, tip_rack, source_plate, target_plate, waste_plate, _trash = _build_simulation_deck()
|
||||||
|
backend_cfg = {
|
||||||
|
"type": "unilabos.devices.liquid_handling.rviz_backend.UniLiquidHandlerRvizBackend",
|
||||||
|
"channel_num": channel_num,
|
||||||
|
"total_height": 310,
|
||||||
|
"lh_device_id": f"pytest_prcxi_{channel_num}",
|
||||||
|
}
|
||||||
|
handler = LiquidHandlerAbstract(
|
||||||
|
backend=backend_cfg,
|
||||||
|
deck=deck,
|
||||||
|
simulator=True,
|
||||||
|
channel_num=channel_num,
|
||||||
|
total_height=310,
|
||||||
|
)
|
||||||
|
run(handler.setup())
|
||||||
|
handler.set_tiprack([tip_rack])
|
||||||
|
handler.support_touch_tip = False
|
||||||
|
|
||||||
|
context = SimulationContext(
|
||||||
|
handler=handler,
|
||||||
|
deck=deck,
|
||||||
|
tip_rack=tip_rack,
|
||||||
|
source_plate=source_plate,
|
||||||
|
target_plate=target_plate,
|
||||||
|
waste_plate=waste_plate,
|
||||||
|
channel_num=channel_num,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield context
|
||||||
|
|
||||||
|
_stop_backend(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_wells(plate: Plate, start: int, count: int) -> List[Well]:
|
||||||
|
wells = plate.children[start : start + count]
|
||||||
|
for well in wells:
|
||||||
|
_ensure_unilabos_extra(well)
|
||||||
|
return wells
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_samples_match(sources: Sequence[Well], targets: Sequence[Well]) -> None:
|
||||||
|
for src, tgt in zip(sources, targets):
|
||||||
|
src_uuid = getattr(src, "unilabos_extra", {}).get("sample_uuid")
|
||||||
|
tgt_uuid = getattr(tgt, "unilabos_extra", {}).get("sample_uuid")
|
||||||
|
assert tgt_uuid == src_uuid
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_liquid_single_channel_one_to_one(prcxi_simulation: SimulationContext):
|
||||||
|
if prcxi_simulation.channel_num != 1:
|
||||||
|
pytest.skip("仅在单通道配置下运行")
|
||||||
|
|
||||||
|
handler = prcxi_simulation.handler
|
||||||
|
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||||||
|
_ensure_unilabos_extra(well)
|
||||||
|
sources = prcxi_simulation.source_plate[0:3]
|
||||||
|
targets = prcxi_simulation.target_plate["A4:A6"]
|
||||||
|
for idx, src in enumerate(sources):
|
||||||
|
_assign_sample_uuid(src, f"single_{idx}")
|
||||||
|
offsets = _zero_offsets(max(len(sources), len(targets)))
|
||||||
|
|
||||||
|
result = run(
|
||||||
|
handler.transfer_liquid(
|
||||||
sources=sources,
|
sources=sources,
|
||||||
targets=targets,
|
targets=targets,
|
||||||
tip_racks=[],
|
tip_racks=[prcxi_simulation.tip_rack],
|
||||||
use_channels=[0],
|
use_channels=[0],
|
||||||
asp_vols=[1, 2, 3],
|
asp_vols=[5.0, 6.0, 7.0],
|
||||||
dis_vols=[4, 5, 6],
|
dis_vols=[10.0, 11.0, 12.0],
|
||||||
mix_times=None, # 应该仍能执行(不 mix)
|
offsets=offsets,
|
||||||
|
mix_times=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
# assert result == """"""
|
||||||
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 都是单孔列表
|
_assert_samples_match(sources, targets)
|
||||||
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():
|
def test_transfer_liquid_single_channel_one_to_many(prcxi_simulation: SimulationContext):
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
if prcxi_simulation.channel_num != 1:
|
||||||
lh.current_tip = iter(make_tip_iter(16))
|
pytest.skip("仅在单通道配置下运行")
|
||||||
|
|
||||||
source = DummyContainer("S0")
|
handler = prcxi_simulation.handler
|
||||||
target = DummyContainer("T0")
|
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||||||
|
_ensure_unilabos_extra(well)
|
||||||
|
source = prcxi_simulation.source_plate.children[0]
|
||||||
|
targets = prcxi_simulation.target_plate["A1:E1"]
|
||||||
|
_assign_sample_uuid(source, "one_to_many_source")
|
||||||
|
offsets = _zero_offsets(max(len(targets), 1))
|
||||||
|
|
||||||
run(
|
run(
|
||||||
lh.transfer_liquid(
|
handler.transfer_liquid(
|
||||||
sources=[source],
|
sources=[source],
|
||||||
targets=[target],
|
targets=targets,
|
||||||
tip_racks=[],
|
tip_racks=[prcxi_simulation.tip_rack],
|
||||||
use_channels=[0],
|
use_channels=[0],
|
||||||
asp_vols=[5],
|
asp_vols=10.0,
|
||||||
dis_vols=[5],
|
dis_vols=[2.0, 2.0, 2.0, 2.0, 2.0],
|
||||||
mix_stage="before",
|
offsets=offsets,
|
||||||
|
mix_times=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for target in targets:
|
||||||
|
assert getattr(target, "unilabos_extra", {}).get("sample_uuid") == "one_to_many_source"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_liquid_single_channel_many_to_one(prcxi_simulation: SimulationContext):
|
||||||
|
if prcxi_simulation.channel_num != 1:
|
||||||
|
pytest.skip("仅在单通道配置下运行")
|
||||||
|
|
||||||
|
handler = prcxi_simulation.handler
|
||||||
|
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||||||
|
_ensure_unilabos_extra(well)
|
||||||
|
sources = prcxi_simulation.source_plate[0:3]
|
||||||
|
target = prcxi_simulation.target_plate.children[4]
|
||||||
|
for idx, src in enumerate(sources):
|
||||||
|
_assign_sample_uuid(src, f"many_to_one_{idx}")
|
||||||
|
offsets = _zero_offsets(max(len(sources), len([target])))
|
||||||
|
|
||||||
|
run(
|
||||||
|
handler.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=[target],
|
||||||
|
tip_racks=[prcxi_simulation.tip_rack],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[8.0, 9.0, 10.0],
|
||||||
|
dis_vols=1,
|
||||||
|
offsets=offsets,
|
||||||
|
mix_stage="after",
|
||||||
mix_times=1,
|
mix_times=1,
|
||||||
|
mix_vol=5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert getattr(target, "unilabos_extra", {}).get("sample_uuid") == "many_to_one_2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_liquid_eight_channel_batches(prcxi_simulation: SimulationContext):
|
||||||
|
if prcxi_simulation.channel_num != 8:
|
||||||
|
pytest.skip("仅在八通道配置下运行")
|
||||||
|
|
||||||
|
handler = prcxi_simulation.handler
|
||||||
|
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||||||
|
_ensure_unilabos_extra(well)
|
||||||
|
sources = prcxi_simulation.source_plate[0:8]
|
||||||
|
targets = prcxi_simulation.target_plate[16:24]
|
||||||
|
for idx, src in enumerate(sources):
|
||||||
|
_assign_sample_uuid(src, f"batch_{idx}")
|
||||||
|
offsets = _zero_offsets(len(targets))
|
||||||
|
|
||||||
|
use_channels = list(range(8))
|
||||||
|
asp_vols = [float(i + 1) * 2 for i in range(8)]
|
||||||
|
dis_vols = [float(i + 10) for i in range(8)]
|
||||||
|
|
||||||
|
run(
|
||||||
|
handler.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[prcxi_simulation.tip_rack],
|
||||||
|
use_channels=use_channels,
|
||||||
|
asp_vols=asp_vols,
|
||||||
|
dis_vols=dis_vols,
|
||||||
|
offsets=offsets,
|
||||||
|
mix_times=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_assert_samples_match(sources, targets)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mix_stage", ["before", "after", "both"])
|
||||||
|
def test_transfer_liquid_mix_stages(prcxi_simulation: SimulationContext, mix_stage: str):
|
||||||
|
if prcxi_simulation.channel_num != 1:
|
||||||
|
pytest.skip("仅在单通道配置下运行")
|
||||||
|
|
||||||
|
handler = prcxi_simulation.handler
|
||||||
|
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||||||
|
_ensure_unilabos_extra(well)
|
||||||
|
target = prcxi_simulation.target_plate[70]
|
||||||
|
sources = prcxi_simulation.source_plate[80:82]
|
||||||
|
for idx, src in enumerate(sources):
|
||||||
|
_assign_sample_uuid(src, f"mix_stage_{mix_stage}_{idx}")
|
||||||
|
|
||||||
|
run(
|
||||||
|
handler.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=[target],
|
||||||
|
tip_racks=[prcxi_simulation.tip_rack],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[4.0, 5.0],
|
||||||
|
dis_vols=1,
|
||||||
|
offsets=_zero_offsets(len(sources)),
|
||||||
|
mix_stage=mix_stage,
|
||||||
|
mix_times=2,
|
||||||
mix_vol=3,
|
mix_vol=3,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
names = [name for name, _ in lh.calls]
|
# mix_stage 前后都应该保留最新源的 sample_uuid
|
||||||
assert names.count("mix") == 1
|
assert getattr(target, "unilabos_extra", {}).get("sample_uuid") == f"mix_stage_{mix_stage}_1"
|
||||||
assert names.index("mix") < names.index("aspirate")
|
if prcxi_simulation.channel_num != 8:
|
||||||
|
pytest.skip("仅在八通道配置下运行")
|
||||||
|
|
||||||
|
handler = prcxi_simulation.handler
|
||||||
|
sources = prcxi_simulation.source_plate[0:8]
|
||||||
|
targets = prcxi_simulation.target_plate[16:24]
|
||||||
|
for idx, src in enumerate(sources):
|
||||||
|
_assign_sample_uuid(src, f"batch_{idx}")
|
||||||
|
offsets = _zero_offsets(len(targets))
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_groups_by_8():
|
use_channels = list(range(8))
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
asp_vols = [float(i + 1) * 2 for i in range(8)]
|
||||||
lh.current_tip = iter(make_tip_iter(256))
|
dis_vols = [float(i + 10) for i in range(8)]
|
||||||
|
|
||||||
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(
|
run(
|
||||||
lh.transfer_liquid(
|
handler.transfer_liquid(
|
||||||
sources=sources,
|
sources=sources,
|
||||||
targets=targets,
|
targets=targets,
|
||||||
tip_racks=[],
|
tip_racks=[prcxi_simulation.tip_rack],
|
||||||
use_channels=list(range(8)),
|
use_channels=use_channels,
|
||||||
asp_vols=asp_vols,
|
asp_vols=asp_vols,
|
||||||
dis_vols=dis_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,
|
offsets=offsets,
|
||||||
liquid_height=liquid_heights,
|
mix_stage="after",
|
||||||
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_times=2,
|
||||||
mix_vol=4,
|
mix_vol=3,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
names = [name for name, _ in lh.calls]
|
_assert_samples_match(sources, targets)
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|||||||
547
tests/devices/liquid_handling/unit_test.py
Normal file
547
tests/devices/liquid_handling/unit_test.py
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
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}))
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||||
|
assert len(aspirate_calls) >= 2
|
||||||
|
mix_idx, mix_payload = aspirate_calls[0]
|
||||||
|
assert mix_payload["resources"] == [target]
|
||||||
|
assert mix_payload["vols"] == [3]
|
||||||
|
transfer_idx, transfer_payload = aspirate_calls[1]
|
||||||
|
assert transfer_payload["resources"] == [source]
|
||||||
|
assert mix_idx < transfer_idx
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||||
|
assert len(aspirate_calls) >= 1
|
||||||
|
mix_idx, mix_payload = aspirate_calls[0]
|
||||||
|
assert mix_payload["resources"] == [target]
|
||||||
|
assert mix_payload["vols"] == [4]
|
||||||
|
# 第一個 mix 之後會真正開始吸 source
|
||||||
|
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mix_single_target_produces_matching_cycles():
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
target = DummyContainer("T_mix")
|
||||||
|
|
||||||
|
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
||||||
|
|
||||||
|
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
|
||||||
|
assert all(call["resources"] == [target] for call in aspirates)
|
||||||
|
assert all(call["vols"] == [5] for call in aspirates)
|
||||||
|
assert all(call["resources"] == [target] for call in dispenses)
|
||||||
|
assert all(call["vols"] == [5] for call in dispenses)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mix_multiple_targets_supports_per_target_offsets():
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
||||||
|
offsets = ["left", "right"]
|
||||||
|
heights = [0.1, 0.2]
|
||||||
|
rates = [0.5, 1.0]
|
||||||
|
|
||||||
|
run(
|
||||||
|
lh.mix(
|
||||||
|
targets=targets,
|
||||||
|
mix_time=1,
|
||||||
|
mix_vol=3,
|
||||||
|
offsets=offsets,
|
||||||
|
height_to_bottom=heights,
|
||||||
|
mix_rate=rates,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||||
|
assert len(aspirates) == 2
|
||||||
|
assert aspirates[0]["resources"] == [targets[0]]
|
||||||
|
assert aspirates[0]["offsets"] == [offsets[0]]
|
||||||
|
assert aspirates[0]["liquid_height"] == [heights[0]]
|
||||||
|
assert aspirates[0]["flow_rates"] == [rates[0]]
|
||||||
|
assert aspirates[1]["resources"] == [targets[1]]
|
||||||
|
assert aspirates[1]["offsets"] == [offsets[1]]
|
||||||
|
assert aspirates[1]["liquid_height"] == [heights[1]]
|
||||||
|
assert aspirates[1]["flow_rates"] == [rates[1]]
|
||||||
|
|
||||||
|
|
||||||
@@ -2,8 +2,9 @@ import pytest
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from pylabrobot.resources import Resource as ResourcePLR
|
||||||
from unilabos.resources.graphio import resource_bioyond_to_plr
|
from unilabos.resources.graphio import resource_bioyond_to_plr
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||||
from unilabos.registry.registry import lab_registry
|
from unilabos.registry.registry import lab_registry
|
||||||
|
|
||||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
{
|
|
||||||
"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.19"
|
__version__ = "0.10.13"
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Entry point for `python -m unilabos`."""
|
|
||||||
|
|
||||||
from unilabos.app.main import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import platform
|
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -18,92 +17,9 @@ unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
|||||||
if unilabos_dir not in sys.path:
|
if unilabos_dir not in sys.path:
|
||||||
sys.path.append(unilabos_dir)
|
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.utils.banner_print import print_status, print_unilab_banner
|
||||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
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 = ""
|
|
||||||
|
|
||||||
RESTART_EXIT_CODE = 42
|
|
||||||
|
|
||||||
|
|
||||||
def _build_child_argv():
|
|
||||||
"""Build sys.argv for child process, stripping supervisor-only arguments."""
|
|
||||||
result = []
|
|
||||||
skip_next = False
|
|
||||||
for arg in sys.argv:
|
|
||||||
if skip_next:
|
|
||||||
skip_next = False
|
|
||||||
continue
|
|
||||||
if arg in ("--restart_mode", "--restart-mode"):
|
|
||||||
continue
|
|
||||||
if arg in ("--auto_restart_count", "--auto-restart-count"):
|
|
||||||
skip_next = True
|
|
||||||
continue
|
|
||||||
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
|
|
||||||
continue
|
|
||||||
result.append(arg)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _run_as_supervisor(max_restarts: int):
|
|
||||||
"""
|
|
||||||
Supervisor process that spawns and monitors child processes.
|
|
||||||
|
|
||||||
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
|
|
||||||
it only launches the real process as a child and restarts it when the child
|
|
||||||
exits with RESTART_EXIT_CODE.
|
|
||||||
"""
|
|
||||||
child_argv = [sys.executable] + _build_child_argv()
|
|
||||||
restart_count = 0
|
|
||||||
|
|
||||||
print_status(
|
|
||||||
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
|
|
||||||
f"child command: {' '.join(child_argv)}",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
print_status(
|
|
||||||
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
process = subprocess.Popen(child_argv)
|
|
||||||
exit_code = process.wait()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print_status("[Supervisor] Interrupted, terminating child process...", "info")
|
|
||||||
process.terminate()
|
|
||||||
try:
|
|
||||||
process.wait(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if exit_code == RESTART_EXIT_CODE:
|
|
||||||
restart_count += 1
|
|
||||||
if restart_count > max_restarts:
|
|
||||||
print_status(
|
|
||||||
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
|
|
||||||
"warning",
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
print_status(
|
|
||||||
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
time.sleep(2)
|
|
||||||
else:
|
|
||||||
if exit_code != 0:
|
|
||||||
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
|
|
||||||
else:
|
|
||||||
print_status("[Supervisor] Child exited normally", "info")
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config_from_file(config_path):
|
def load_config_from_file(config_path):
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
@@ -145,13 +61,6 @@ def parse_args():
|
|||||||
action="append",
|
action="append",
|
||||||
help="Path to the registry directory",
|
help="Path to the registry directory",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--devices",
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
action="append",
|
|
||||||
help="Path to Python code directory for AST-based device/resource scanning",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--working_dir",
|
"--working_dir",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -241,52 +150,11 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip environment dependency check on startup",
|
help="Skip environment dependency check on startup",
|
||||||
)
|
)
|
||||||
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(
|
parser.add_argument(
|
||||||
"--complete_registry",
|
"--complete_registry",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help="Complete and rewrite YAML registry files using AST analysis results",
|
help="Complete registry information",
|
||||||
)
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--external_devices_only",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--extra_resource",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--restart_mode",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--auto_restart_count",
|
|
||||||
type=int,
|
|
||||||
default=500,
|
|
||||||
help="Maximum number of automatic restarts in restart mode (default: 500)",
|
|
||||||
)
|
)
|
||||||
# workflow upload subcommand
|
# workflow upload subcommand
|
||||||
workflow_parser = subparsers.add_parser(
|
workflow_parser = subparsers.add_parser(
|
||||||
@@ -321,12 +189,6 @@ def parse_args():
|
|||||||
default=False,
|
default=False,
|
||||||
help="Whether to publish the workflow (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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -338,102 +200,61 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args_dict = vars(args)
|
args_dict = vars(args)
|
||||||
|
|
||||||
# Supervisor mode: spawn child processes and monitor for restart
|
|
||||||
if args_dict.get("restart_mode", False):
|
|
||||||
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
|
|
||||||
return
|
|
||||||
|
|
||||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||||
skip_env_check = args_dict.get("skip_env_check", False)
|
if not args_dict.get("skip_env_check", False):
|
||||||
check_mode = args_dict.get("check_mode", False)
|
from unilabos.utils.environment_check import check_environment
|
||||||
|
|
||||||
if not skip_env_check:
|
|
||||||
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
|
|
||||||
|
|
||||||
if not check_environment(auto_install=True):
|
if not check_environment(auto_install=True):
|
||||||
print_status("环境检查失败,程序退出", "error")
|
print_status("环境检查失败,程序退出", "error")
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
# 第一次设备包依赖检查:build_registry 之前,确保 import map 可用
|
|
||||||
devices_dirs_for_req = args_dict.get("devices", None)
|
|
||||||
if devices_dirs_for_req:
|
|
||||||
if not check_device_package_requirements(devices_dirs_for_req):
|
|
||||||
print_status("设备包依赖检查失败,程序退出", "error")
|
|
||||||
os._exit(1)
|
|
||||||
else:
|
else:
|
||||||
print_status("跳过环境依赖检查", "warning")
|
print_status("跳过环境依赖检查", "warning")
|
||||||
|
|
||||||
# 加载配置文件,优先加载config,然后从env读取
|
# 加载配置文件,优先加载config,然后从env读取
|
||||||
config_path = args_dict.get("config")
|
config_path = args_dict.get("config")
|
||||||
|
if os.getcwd().endswith("unilabos_data"):
|
||||||
# === 解析 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(os.path.abspath(config_path))
|
|
||||||
else:
|
|
||||||
working_dir = os.path.abspath(os.getcwd())
|
working_dir = os.path.abspath(os.getcwd())
|
||||||
|
else:
|
||||||
|
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||||
|
|
||||||
# unilabos_data 子目录自动检测
|
if args_dict.get("working_dir"):
|
||||||
if os.path.basename(working_dir) != "unilabos_data":
|
working_dir = args_dict.get("working_dir", "")
|
||||||
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
|
if config_path and not os.path.exists(config_path):
|
||||||
if os.path.isdir(unilabos_data_sub):
|
config_path = os.path.join(working_dir, "local_config.py")
|
||||||
working_dir = unilabos_data_sub
|
if not os.path.exists(config_path):
|
||||||
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
|
print_status(
|
||||||
# 未显式指定路径,默认使用 cwd/unilabos_data
|
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
|
||||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
"error",
|
||||||
|
|
||||||
# === 解析 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)
|
os._exit(1)
|
||||||
|
elif config_path and os.path.exists(config_path):
|
||||||
# 加载配置文件 (check_mode 跳过)
|
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")
|
||||||
|
else:
|
||||||
|
os._exit(1)
|
||||||
|
# 加载配置文件
|
||||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||||
if not check_mode:
|
load_config_from_file(config_path)
|
||||||
load_config_from_file(config_path)
|
|
||||||
|
|
||||||
# 根据配置重新设置日志级别
|
# 根据配置重新设置日志级别
|
||||||
from unilabos.utils.log import configure_logger, logger
|
from unilabos.utils.log import configure_logger, logger
|
||||||
|
|
||||||
if hasattr(BasicConfig, "log_level"):
|
if hasattr(BasicConfig, "log_level"):
|
||||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||||
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
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 != parser.get_default("addr"):
|
||||||
if args.addr == "test":
|
if args.addr == "test":
|
||||||
@@ -476,67 +297,41 @@ def main():
|
|||||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||||
BasicConfig.upload_registry = args_dict.get("upload_registry", 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.extra_resource = args_dict.get("extra_resource", False)
|
|
||||||
if BasicConfig.extra_resource:
|
|
||||||
print_status("启用额外资源加载:将加载lab_开头的labware资源定义", "info")
|
|
||||||
BasicConfig.communication_protocol = "websocket"
|
BasicConfig.communication_protocol = "websocket"
|
||||||
machine_name = platform.node()
|
machine_name = os.popen("hostname").read().strip()
|
||||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||||
BasicConfig.machine_name = machine_name
|
BasicConfig.machine_name = machine_name
|
||||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||||
BasicConfig.check_mode = check_mode
|
|
||||||
|
|
||||||
from unilabos.registry.registry import build_registry
|
|
||||||
|
|
||||||
# 显示启动横幅
|
|
||||||
print_unilab_banner(args_dict)
|
|
||||||
|
|
||||||
# Step 0: AST 分析优先 + YAML 注册表加载
|
|
||||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
|
||||||
devices_dirs = args_dict.get("devices", None)
|
|
||||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
|
||||||
external_only = args_dict.get("external_devices_only", False)
|
|
||||||
lab_registry = build_registry(
|
|
||||||
registry_paths=args_dict["registry_path"],
|
|
||||||
devices_dirs=devices_dirs,
|
|
||||||
upload_registry=BasicConfig.upload_registry,
|
|
||||||
check_mode=check_mode,
|
|
||||||
complete_registry=complete_registry,
|
|
||||||
external_only=external_only,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check mode: 注册表验证完成后直接退出
|
|
||||||
if check_mode:
|
|
||||||
device_count = len(lab_registry.device_type_registry)
|
|
||||||
resource_count = len(lab_registry.resource_type_registry)
|
|
||||||
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
# 以下导入依赖 ROS2 环境,check_mode 已退出不需要
|
|
||||||
from unilabos.resources.graphio import (
|
from unilabos.resources.graphio import (
|
||||||
read_node_link_json,
|
read_node_link_json,
|
||||||
read_graphml,
|
read_graphml,
|
||||||
dict_from_graph,
|
dict_from_graph,
|
||||||
modify_to_backend_format,
|
|
||||||
)
|
)
|
||||||
from unilabos.app.communication import get_communication_client
|
from unilabos.app.communication import get_communication_client
|
||||||
|
from unilabos.registry.registry import build_registry
|
||||||
from unilabos.app.backend import start_backend
|
from unilabos.app.backend import start_backend
|
||||||
from unilabos.app.web import http_client
|
from unilabos.app.web import http_client
|
||||||
from unilabos.app.web import start_server
|
from unilabos.app.web import start_server
|
||||||
from unilabos.app.register import register_devices_and_resources
|
from unilabos.app.register import register_devices_and_resources
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
from unilabos.resources.graphio import modify_to_backend_format
|
||||||
|
from unilabos.ros.nodes.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
|
||||||
|
)
|
||||||
|
|
||||||
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
|
||||||
if BasicConfig.upload_registry:
|
if BasicConfig.upload_registry:
|
||||||
|
# 设备注册到服务端 - 需要 ak 和 sk
|
||||||
if BasicConfig.ak and BasicConfig.sk:
|
if BasicConfig.ak and BasicConfig.sk:
|
||||||
# print_status("开始注册设备到服务端...", "info")
|
print_status("开始注册设备到服务端...", "info")
|
||||||
try:
|
try:
|
||||||
register_devices_and_resources(lab_registry)
|
register_devices_and_resources(lab_registry)
|
||||||
# print_status("设备注册完成", "info")
|
print_status("设备注册完成", "info")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_status(f"设备注册失败: {e}", "error")
|
print_status(f"设备注册失败: {e}", "error")
|
||||||
else:
|
else:
|
||||||
@@ -621,16 +416,12 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
if request_startup_json and "nodes" in request_startup_json:
|
||||||
print_status("开始同步远端物料到本地...", "info")
|
print_status("开始同步远端物料到本地...", "info")
|
||||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
remote_tree_set = ResourceTreeSet.from_raw_list(request_startup_json["nodes"])
|
||||||
resource_tree_set.merge_remote_resources(remote_tree_set)
|
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||||
print_status("远端物料同步完成", "info")
|
print_status("远端物料同步完成", "info")
|
||||||
|
|
||||||
# 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements
|
|
||||||
# TODO: 当 community device package 功能上线后,在这里调用
|
|
||||||
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
|
|
||||||
|
|
||||||
# 使用 ResourceTreeSet 代替 list
|
# 使用 ResourceTreeSet 代替 list
|
||||||
args_dict["resources_config"] = resource_tree_set
|
args_dict["resources_config"] = resource_tree_set
|
||||||
args_dict["devices_config"] = resource_tree_set
|
args_dict["devices_config"] = resource_tree_set
|
||||||
@@ -706,26 +497,16 @@ def main():
|
|||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
else:
|
||||||
start_backend(**args_dict)
|
start_backend(**args_dict)
|
||||||
restart_requested = start_server(
|
start_server(
|
||||||
open_browser=not args_dict["disable_browser"],
|
open_browser=not args_dict["disable_browser"],
|
||||||
port=BasicConfig.port,
|
port=BasicConfig.port,
|
||||||
)
|
)
|
||||||
if restart_requested:
|
|
||||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
|
||||||
cleanup_for_restart()
|
|
||||||
return
|
|
||||||
else:
|
else:
|
||||||
start_backend(**args_dict)
|
start_backend(**args_dict)
|
||||||
|
start_server(
|
||||||
# 启动服务器(默认支持WebSocket触发重启)
|
|
||||||
restart_requested = start_server(
|
|
||||||
open_browser=not args_dict["disable_browser"],
|
open_browser=not args_dict["disable_browser"],
|
||||||
port=BasicConfig.port,
|
port=BasicConfig.port,
|
||||||
)
|
)
|
||||||
if restart_requested:
|
|
||||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
|
||||||
cleanup_for_restart()
|
|
||||||
os._exit(RESTART_EXIT_CODE)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ class JobAddReq(BaseModel):
|
|||||||
action_type: str = Field(
|
action_type: str = Field(
|
||||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
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)
|
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="")
|
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="")
|
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Optional, Tuple, Dict, Any
|
||||||
|
|
||||||
from unilabos.utils.log import logger
|
from unilabos.utils.log import logger
|
||||||
from unilabos.utils.tools import normalize_json as _normalize_device
|
from unilabos.utils.type_check import TypeEncoder
|
||||||
|
|
||||||
|
|
||||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||||
@@ -10,63 +11,50 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
|||||||
注册设备和资源到服务器(仅支持HTTP)
|
注册设备和资源到服务器(仅支持HTTP)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 注册资源信息 - 使用HTTP方式
|
||||||
from unilabos.app.web.client import http_client
|
from unilabos.app.web.client import http_client
|
||||||
|
|
||||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||||
|
|
||||||
|
# 注册设备信息
|
||||||
devices_to_register = {}
|
devices_to_register = {}
|
||||||
for device_info in lab_registry.obtain_registry_device_info():
|
for device_info in lab_registry.obtain_registry_device_info():
|
||||||
devices_to_register[device_info["id"]] = _normalize_device(device_info)
|
devices_to_register[device_info["id"]] = json.loads(
|
||||||
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
|
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
||||||
|
)
|
||||||
|
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||||
|
|
||||||
resources_to_register = {}
|
resources_to_register = {}
|
||||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||||
resources_to_register[resource_info["id"]] = resource_info
|
resources_to_register[resource_info["id"]] = resource_info
|
||||||
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||||
|
|
||||||
if gather_only:
|
if gather_only:
|
||||||
return devices_to_register, resources_to_register
|
return devices_to_register, resources_to_register
|
||||||
|
# 注册设备
|
||||||
if devices_to_register:
|
if devices_to_register:
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = http_client.resource_registry(
|
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||||
{"resources": list(devices_to_register.values())},
|
|
||||||
tag="device_registry",
|
|
||||||
)
|
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
res_data = response.json() if response.status_code == 200 else {}
|
if response.status_code in [200, 201]:
|
||||||
skipped = res_data.get("data", {}).get("skipped", False)
|
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
||||||
if skipped:
|
|
||||||
logger.info(
|
|
||||||
f"[UniLab Register] 设备注册跳过(内容未变化)"
|
|
||||||
f" {len(devices_to_register)} 个 {cost_time:.3f}s"
|
|
||||||
)
|
|
||||||
elif response.status_code in [200, 201]:
|
|
||||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||||
|
|
||||||
|
# 注册资源
|
||||||
if resources_to_register:
|
if resources_to_register:
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = http_client.resource_registry(
|
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||||
{"resources": list(resources_to_register.values())},
|
|
||||||
tag="resource_registry",
|
|
||||||
)
|
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
res_data = response.json() if response.status_code == 200 else {}
|
if response.status_code in [200, 201]:
|
||||||
skipped = res_data.get("data", {}).get("skipped", False)
|
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
||||||
if skipped:
|
|
||||||
logger.info(
|
|
||||||
f"[UniLab Register] 资源注册跳过(内容未变化)"
|
|
||||||
f" {len(resources_to_register)} 个 {cost_time:.3f}s"
|
|
||||||
)
|
|
||||||
elif response.status_code in [200, 201]:
|
|
||||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||||
|
|
||||||
|
logger.info("[UniLab Register] 设备和资源注册完成.")
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
|
|||||||
"result": {},
|
"result": {},
|
||||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||||
"handles": {},
|
"handles": [],
|
||||||
}
|
}
|
||||||
# 不生成已配置action的动作
|
# 不生成已配置action的动作
|
||||||
for k, v in enhanced_info["action_methods"].items()
|
for k, v in enhanced_info["action_methods"].items()
|
||||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
|||||||
# 启动广播任务
|
# 启动广播任务
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
asyncio.create_task(broadcast_device_status())
|
||||||
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
asyncio.create_task(broadcast_status_page_data())
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ HTTP客户端模块
|
|||||||
|
|
||||||
提供与远程服务器通信的客户端功能,只有host需要用
|
提供与远程服务器通信的客户端功能,只有host需要用
|
||||||
"""
|
"""
|
||||||
import gzip
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
from threading import Thread
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||||
from unilabos.utils.log import info
|
from unilabos.utils.log import info
|
||||||
from unilabos.config.config import HTTPConfig, BasicConfig
|
from unilabos.config.config import HTTPConfig, BasicConfig
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
@@ -282,54 +282,22 @@ class HTTPClient:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def resource_registry(
|
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||||
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
|
||||||
) -> requests.Response:
|
|
||||||
"""
|
"""
|
||||||
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
注册资源到服务器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||||
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
# 序列化一次,同时用于保存和发送
|
|
||||||
json_bytes = _fast_dumps(registry_data)
|
|
||||||
|
|
||||||
# 保存请求数据到 unilabos_data
|
|
||||||
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
|
|
||||||
try:
|
|
||||||
os.makedirs(BasicConfig.working_dir, exist_ok=True)
|
|
||||||
with open(req_path, "wb") as f:
|
|
||||||
f.write(_fast_dumps_pretty(registry_data))
|
|
||||||
logger.trace(f"注册表请求数据已保存: {req_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"保存注册表请求数据失败: {e}")
|
|
||||||
|
|
||||||
compressed_body = gzip.compress(json_bytes)
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Lab {self.auth}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Content-Encoding": "gzip",
|
|
||||||
}
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self.remote_addr}/lab/resource",
|
f"{self.remote_addr}/lab/resource",
|
||||||
data=compressed_body,
|
json=registry_data,
|
||||||
headers=headers,
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 保存响应数据到 unilabos_data
|
|
||||||
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
|
|
||||||
try:
|
|
||||||
with open(res_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(f"{response.status_code}\n{response.text}")
|
|
||||||
logger.trace(f"注册表响应数据已保存: {res_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"保存注册表响应数据失败: {e}")
|
|
||||||
|
|
||||||
if response.status_code not in [200, 201]:
|
if response.status_code not in [200, 201]:
|
||||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -377,10 +345,9 @@ class HTTPClient:
|
|||||||
edges: List[Dict[str, Any]],
|
edges: List[Dict[str, Any]],
|
||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
published: bool = False,
|
published: bool = False,
|
||||||
description: str = "",
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
导入工作流到服务器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: 工作流名称(顶层)
|
name: 工作流名称(顶层)
|
||||||
@@ -390,12 +357,13 @@ class HTTPClient:
|
|||||||
edges: 工作流边列表
|
edges: 工作流边列表
|
||||||
tags: 工作流标签列表,默认为空列表
|
tags: 工作流标签列表,默认为空列表
|
||||||
published: 是否发布工作流,默认为False
|
published: 是否发布工作流,默认为False
|
||||||
description: 工作流描述,发布时使用
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||||
"""
|
"""
|
||||||
|
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
|
||||||
payload = {
|
payload = {
|
||||||
|
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
|
||||||
"name": name,
|
"name": name,
|
||||||
"data": {
|
"data": {
|
||||||
"workflow_uuid": workflow_uuid,
|
"workflow_uuid": workflow_uuid,
|
||||||
@@ -403,6 +371,7 @@ class HTTPClient:
|
|||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
"edges": edges,
|
"edges": edges,
|
||||||
"tags": tags if tags is not None else [],
|
"tags": tags if tags is not None else [],
|
||||||
|
"published": published,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
# 保存请求到文件
|
# 保存请求到文件
|
||||||
@@ -423,51 +392,11 @@ class HTTPClient:
|
|||||||
res = response.json()
|
res = response.json()
|
||||||
if "code" in res and res["code"] != 0:
|
if "code" in res and res["code"] != 0:
|
||||||
logger.error(f"导入工作流失败: {response.text}")
|
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
|
return res
|
||||||
else:
|
else:
|
||||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||||
return {"code": response.status_code, "message": 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()
|
http_client = HTTPClient()
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
|||||||
feedback=feedback or {},
|
feedback=feedback or {},
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
)
|
)
|
||||||
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||||
|
|
||||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||||
"""获取并删除任务结果"""
|
"""获取并删除任务结果"""
|
||||||
with self._results_lock:
|
with self._results_lock:
|
||||||
result = self._results.pop(job_id, None)
|
result = self._results.pop(job_id, None)
|
||||||
if result:
|
if result:
|
||||||
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||||
@@ -327,7 +327,6 @@ def job_add(req: JobAddReq) -> JobData:
|
|||||||
queue_item,
|
queue_item,
|
||||||
action_type=action_type,
|
action_type=action_type,
|
||||||
action_kwargs=action_args,
|
action_kwargs=action_args,
|
||||||
sample_material=req.sample_material,
|
|
||||||
server_info=server_info,
|
server_info=server_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Web服务器模块
|
|||||||
|
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
@@ -86,7 +87,7 @@ def setup_server() -> FastAPI:
|
|||||||
# 设置页面路由
|
# 设置页面路由
|
||||||
try:
|
try:
|
||||||
setup_web_pages(pages)
|
setup_web_pages(pages)
|
||||||
# info("[Web] 已加载Web UI模块")
|
info("[Web] 已加载Web UI模块")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -95,7 +96,7 @@ def setup_server() -> FastAPI:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> bool:
|
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
启动服务器
|
启动服务器
|
||||||
|
|
||||||
@@ -103,14 +104,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
|||||||
host: 服务器主机
|
host: 服务器主机
|
||||||
port: 服务器端口
|
port: 服务器端口
|
||||||
open_browser: 是否自动打开浏览器
|
open_browser: 是否自动打开浏览器
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if restart was requested, False otherwise
|
|
||||||
"""
|
"""
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from uvicorn import Config, Server
|
|
||||||
|
|
||||||
# 设置服务器
|
# 设置服务器
|
||||||
setup_server()
|
setup_server()
|
||||||
|
|
||||||
@@ -129,37 +123,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
|||||||
|
|
||||||
# 启动服务器
|
# 启动服务器
|
||||||
info(f"[Web] 启动FastAPI服务器: {host}:{port}")
|
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,10 +23,9 @@ from typing import Optional, Dict, Any, List
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
from jedi.inference.gradual.typing import TypedDict
|
||||||
|
|
||||||
from unilabos.app.model import JobAddReq
|
from unilabos.app.model import JobAddReq
|
||||||
from unilabos.resources.resource_tracker import ResourceDictType
|
|
||||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||||
from unilabos.utils.type_check import serialize_result_info
|
from unilabos.utils.type_check import serialize_result_info
|
||||||
from unilabos.app.communication import BaseCommunicationClient
|
from unilabos.app.communication import BaseCommunicationClient
|
||||||
@@ -77,7 +76,6 @@ class JobInfo:
|
|||||||
start_time: float
|
start_time: float
|
||||||
last_update_time: float = field(default_factory=time.time)
|
last_update_time: float = field(default_factory=time.time)
|
||||||
ready_timeout: Optional[float] = None # READY状态的超时时间
|
ready_timeout: Optional[float] = None # READY状态的超时时间
|
||||||
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
|
|
||||||
|
|
||||||
def update_timestamp(self):
|
def update_timestamp(self):
|
||||||
"""更新最后更新时间"""
|
"""更新最后更新时间"""
|
||||||
@@ -129,15 +127,6 @@ class DeviceActionManager:
|
|||||||
# 总是将job添加到all_jobs中
|
# 总是将job添加到all_jobs中
|
||||||
self.all_jobs[job_info.job_id] = job_info
|
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:
|
if device_key in self.active_jobs:
|
||||||
# 有正在执行或准备执行的任务,加入队列
|
# 有正在执行或准备执行的任务,加入队列
|
||||||
@@ -165,7 +154,7 @@ class DeviceActionManager:
|
|||||||
job_info.set_ready_timeout(10) # 设置10秒超时
|
job_info.set_ready_timeout(10) # 设置10秒超时
|
||||||
self.active_jobs[device_key] = job_info
|
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)
|
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} can start immediately for {device_key}")
|
logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def start_job(self, job_id: str) -> bool:
|
def start_job(self, job_id: str) -> bool:
|
||||||
@@ -187,15 +176,11 @@ class DeviceActionManager:
|
|||||||
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# always_free的job不需要检查active_jobs
|
# 检查设备上是否是这个job
|
||||||
if not job_info.always_free:
|
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||||
# 检查设备上是否是这个job
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||||
job_log = format_job_log(
|
return False
|
||||||
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
|
# 开始执行任务,将状态从READY转换为STARTED
|
||||||
job_info.status = JobStatus.STARTED
|
job_info.status = JobStatus.STARTED
|
||||||
@@ -218,13 +203,6 @@ class DeviceActionManager:
|
|||||||
job_info = self.all_jobs[job_id]
|
job_info = self.all_jobs[job_id]
|
||||||
device_key = job_info.device_action_key
|
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:
|
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||||
del self.active_jobs[device_key]
|
del self.active_jobs[device_key]
|
||||||
@@ -232,9 +210,8 @@ class DeviceActionManager:
|
|||||||
job_info.update_timestamp()
|
job_info.update_timestamp()
|
||||||
# 从all_jobs中移除已结束的job
|
# 从all_jobs中移除已结束的job
|
||||||
del self.all_jobs[job_id]
|
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)
|
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}")
|
logger.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
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}")
|
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
||||||
@@ -250,20 +227,15 @@ class DeviceActionManager:
|
|||||||
next_job_log = format_job_log(
|
next_job_log = format_job_log(
|
||||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||||
)
|
)
|
||||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||||
return next_job
|
return next_job
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_active_jobs(self) -> List[JobInfo]:
|
def get_active_jobs(self) -> List[JobInfo]:
|
||||||
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
|
"""获取所有正在执行的任务"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
jobs = list(self.active_jobs.values())
|
return 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]:
|
def get_queued_jobs(self) -> List[JobInfo]:
|
||||||
"""获取所有排队中的任务"""
|
"""获取所有排队中的任务"""
|
||||||
@@ -288,14 +260,6 @@ class DeviceActionManager:
|
|||||||
job_info = self.all_jobs[job_id]
|
job_info = self.all_jobs[job_id]
|
||||||
device_key = job_info.device_action_key
|
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:
|
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||||
# 清理active job状态
|
# 清理active job状态
|
||||||
@@ -304,7 +268,7 @@ class DeviceActionManager:
|
|||||||
# 从all_jobs中移除
|
# 从all_jobs中移除
|
||||||
del self.all_jobs[job_id]
|
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)
|
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] Active job {job_log} cancelled for {device_key}")
|
logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||||
|
|
||||||
# 启动下一个任务
|
# 启动下一个任务
|
||||||
if device_key in self.device_queues and self.device_queues[device_key]:
|
if device_key in self.device_queues and self.device_queues[device_key]:
|
||||||
@@ -317,7 +281,7 @@ class DeviceActionManager:
|
|||||||
next_job_log = format_job_log(
|
next_job_log = format_job_log(
|
||||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||||
)
|
)
|
||||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 如果是排队中的任务
|
# 如果是排队中的任务
|
||||||
@@ -331,7 +295,7 @@ class DeviceActionManager:
|
|||||||
job_log = format_job_log(
|
job_log = format_job_log(
|
||||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||||
)
|
)
|
||||||
logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
@@ -369,18 +333,13 @@ class DeviceActionManager:
|
|||||||
timeout_jobs = []
|
timeout_jobs = []
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
|
# 统计READY状态的任务数量
|
||||||
ready_candidates = list(self.active_jobs.values())
|
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
|
||||||
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:
|
if ready_jobs_count > 0:
|
||||||
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
||||||
|
|
||||||
# 找到所有超时的READY任务(只检测,不处理)
|
# 找到所有超时的READY任务(只检测,不处理)
|
||||||
for job_info in ready_candidates:
|
for job_info in self.active_jobs.values():
|
||||||
if job_info.is_ready_timeout():
|
if job_info.is_ready_timeout():
|
||||||
timeout_jobs.append(job_info)
|
timeout_jobs.append(job_info)
|
||||||
job_log = format_job_log(
|
job_log = format_job_log(
|
||||||
@@ -400,7 +359,7 @@ class MessageProcessor:
|
|||||||
self.device_manager = device_manager
|
self.device_manager = device_manager
|
||||||
self.queue_processor = None # 延迟设置
|
self.queue_processor = None # 延迟设置
|
||||||
self.websocket_client = None # 延迟设置
|
self.websocket_client = None # 延迟设置
|
||||||
self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id
|
self.session_id = ""
|
||||||
|
|
||||||
# WebSocket连接
|
# WebSocket连接
|
||||||
self.websocket = None
|
self.websocket = None
|
||||||
@@ -409,7 +368,6 @@ class MessageProcessor:
|
|||||||
# 线程控制
|
# 线程控制
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self._loop = None # asyncio event loop引用,用于外部关闭websocket
|
|
||||||
self.reconnect_count = 0
|
self.reconnect_count = 0
|
||||||
|
|
||||||
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
||||||
@@ -436,31 +394,22 @@ class MessageProcessor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止消息处理线程"""
|
"""停止消息处理线程"""
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
# 主动关闭websocket以快速中断消息接收循环
|
|
||||||
ws = self.websocket
|
|
||||||
loop = self._loop
|
|
||||||
if ws and loop and loop.is_running():
|
|
||||||
try:
|
|
||||||
asyncio.run_coroutine_threadsafe(ws.close(), loop)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if self.thread and self.thread.is_alive():
|
if self.thread and self.thread.is_alive():
|
||||||
self.thread.join(timeout=2)
|
self.thread.join(timeout=2)
|
||||||
logger.info("[MessageProcessor] Stopped")
|
logger.info("[MessageProcessor] Stopped")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""运行消息处理主循环"""
|
"""运行消息处理主循环"""
|
||||||
self._loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
asyncio.set_event_loop(self._loop)
|
asyncio.set_event_loop(loop)
|
||||||
self._loop.run_until_complete(self._connection_handler())
|
loop.run_until_complete(self._connection_handler())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
if self._loop:
|
if loop:
|
||||||
self._loop.close()
|
loop.close()
|
||||||
self._loop = None
|
|
||||||
|
|
||||||
async def _connection_handler(self):
|
async def _connection_handler(self):
|
||||||
"""处理WebSocket连接和重连逻辑"""
|
"""处理WebSocket连接和重连逻辑"""
|
||||||
@@ -477,10 +426,8 @@ class MessageProcessor:
|
|||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
self.websocket_url,
|
self.websocket_url,
|
||||||
ssl=ssl_context,
|
ssl=ssl_context,
|
||||||
open_timeout=20,
|
|
||||||
ping_interval=WSConfig.ping_interval,
|
ping_interval=WSConfig.ping_interval,
|
||||||
ping_timeout=10,
|
ping_timeout=10,
|
||||||
close_timeout=5,
|
|
||||||
additional_headers={
|
additional_headers={
|
||||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||||
"EdgeSession": f"{self.session_id}",
|
"EdgeSession": f"{self.session_id}",
|
||||||
@@ -491,94 +438,68 @@ class MessageProcessor:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self.reconnect_count = 0
|
self.reconnect_count = 0
|
||||||
|
|
||||||
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
|
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||||
|
|
||||||
# 启动发送协程
|
# 启动发送协程
|
||||||
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
|
send_task = asyncio.create_task(self._send_handler())
|
||||||
|
|
||||||
# 每次连接(含重连)后重新向服务端注册,
|
|
||||||
# 否则服务端不知道客户端已上线,不会推送消息。
|
|
||||||
if self.websocket_client:
|
|
||||||
self.websocket_client.publish_host_ready()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 接收消息循环
|
# 接收消息循环
|
||||||
await self._message_handler()
|
await self._message_handler()
|
||||||
finally:
|
finally:
|
||||||
# 必须在 async with __aexit__ 之前停止 send_task,
|
|
||||||
# 否则 send_task 会在关闭握手期间继续发送数据,
|
|
||||||
# 干扰 websockets 库的内部清理,导致 task 泄漏。
|
|
||||||
self.connected = False
|
|
||||||
send_task.cancel()
|
send_task.cancel()
|
||||||
try:
|
try:
|
||||||
await send_task
|
await send_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
except websockets.exceptions.ConnectionClosed:
|
except websockets.exceptions.ConnectionClosed:
|
||||||
logger.warning("[MessageProcessor] 与服务端连接中断")
|
logger.warning("[MessageProcessor] Connection closed")
|
||||||
except TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
|
|
||||||
)
|
|
||||||
except websockets.exceptions.InvalidStatus as e:
|
|
||||||
logger.warning(
|
|
||||||
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
|
|
||||||
finally:
|
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
self.connected = False
|
||||||
|
finally:
|
||||||
self.websocket = None
|
self.websocket = None
|
||||||
|
|
||||||
# 重连逻辑
|
# 重连逻辑
|
||||||
if not self.is_running:
|
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||||
break
|
|
||||||
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
|
||||||
self.reconnect_count += 1
|
self.reconnect_count += 1
|
||||||
backoff = WSConfig.reconnect_interval
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
|
||||||
|
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(backoff)
|
await asyncio.sleep(WSConfig.reconnect_interval)
|
||||||
else:
|
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
||||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
self.reconnect_count -= 1
|
||||||
|
|
||||||
async def _message_handler(self):
|
async def _message_handler(self):
|
||||||
"""处理接收到的消息。
|
"""处理接收到的消息"""
|
||||||
|
|
||||||
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler,
|
|
||||||
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
|
|
||||||
正确清理内部 task,避免 task 泄漏。
|
|
||||||
"""
|
|
||||||
if not self.websocket:
|
if not self.websocket:
|
||||||
logger.error("[MessageProcessor] WebSocket connection is None")
|
logger.error("[MessageProcessor] WebSocket connection is None")
|
||||||
return
|
return
|
||||||
|
|
||||||
async for message in self.websocket:
|
try:
|
||||||
try:
|
async for message in self.websocket:
|
||||||
data = json.loads(message)
|
try:
|
||||||
message_type = data.get("action", "")
|
data = json.loads(message)
|
||||||
message_data = data.get("data")
|
await self._process_message(data)
|
||||||
if self.session_id and self.session_id == data.get("edge_session"):
|
except json.JSONDecodeError:
|
||||||
await self._process_message(message_type, message_data)
|
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||||
else:
|
except Exception as e:
|
||||||
if message_type.endswith("_material"):
|
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||||
logger.trace(
|
logger.error(traceback.format_exc())
|
||||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
|
||||||
)
|
except websockets.exceptions.ConnectionClosed:
|
||||||
logger.debug(
|
logger.info("[MessageProcessor] Message handler stopped - connection closed")
|
||||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
except Exception as e:
|
||||||
)
|
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
|
||||||
else:
|
logger.error(traceback.format_exc())
|
||||||
await self._process_message(message_type, message_data)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
async def _send_handler(self):
|
async def _send_handler(self):
|
||||||
"""处理发送队列中的消息"""
|
"""处理发送队列中的消息"""
|
||||||
@@ -610,7 +531,7 @@ class MessageProcessor:
|
|||||||
try:
|
try:
|
||||||
message_str = json.dumps(msg, ensure_ascii=False)
|
message_str = json.dumps(msg, ensure_ascii=False)
|
||||||
await self.websocket.send(message_str)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
@@ -627,16 +548,18 @@ class MessageProcessor:
|
|||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug("[MessageProcessor] Send handler cancelled")
|
logger.debug("[MessageProcessor] Send handler cancelled")
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
logger.debug("[MessageProcessor] Send handler stopped")
|
logger.debug("[MessageProcessor] Send handler stopped")
|
||||||
|
|
||||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
async def _process_message(self, data: Dict[str, Any]):
|
||||||
"""处理收到的消息"""
|
"""处理收到的消息"""
|
||||||
logger.trace(f"[MessageProcessor] Processing message: {message_type}")
|
message_type = data.get("action", "")
|
||||||
|
message_data = data.get("data")
|
||||||
|
|
||||||
|
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if message_type == "pong":
|
if message_type == "pong":
|
||||||
@@ -648,23 +571,14 @@ class MessageProcessor:
|
|||||||
elif message_type == "cancel_action" or message_type == "cancel_task":
|
elif message_type == "cancel_action" or message_type == "cancel_task":
|
||||||
await self._handle_cancel_action(message_data)
|
await self._handle_cancel_action(message_data)
|
||||||
elif message_type == "add_material":
|
elif message_type == "add_material":
|
||||||
# noinspection PyTypeChecker
|
|
||||||
await self._handle_resource_tree_update(message_data, "add")
|
await self._handle_resource_tree_update(message_data, "add")
|
||||||
elif message_type == "update_material":
|
elif message_type == "update_material":
|
||||||
# noinspection PyTypeChecker
|
|
||||||
await self._handle_resource_tree_update(message_data, "update")
|
await self._handle_resource_tree_update(message_data, "update")
|
||||||
elif message_type == "remove_material":
|
elif message_type == "remove_material":
|
||||||
# noinspection PyTypeChecker
|
|
||||||
await self._handle_resource_tree_update(message_data, "remove")
|
await self._handle_resource_tree_update(message_data, "remove")
|
||||||
# elif message_type == "session_id":
|
elif message_type == "session_id":
|
||||||
# self.session_id = message_data.get("session_id")
|
self.session_id = message_data.get("session_id")
|
||||||
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||||
elif message_type == "add_device":
|
|
||||||
await self._handle_device_manage(message_data, "add")
|
|
||||||
elif message_type == "remove_device":
|
|
||||||
await self._handle_device_manage(message_data, "remove")
|
|
||||||
elif message_type == "request_restart":
|
|
||||||
await self._handle_request_restart(message_data)
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||||
|
|
||||||
@@ -678,24 +592,6 @@ class MessageProcessor:
|
|||||||
if host_node:
|
if host_node:
|
||||||
host_node.handle_pong_response(pong_data)
|
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]):
|
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
||||||
"""处理query_action_state消息"""
|
"""处理query_action_state消息"""
|
||||||
device_id = data.get("device_id", "")
|
device_id = data.get("device_id", "")
|
||||||
@@ -710,9 +606,6 @@ class MessageProcessor:
|
|||||||
|
|
||||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
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_info = JobInfo(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -722,7 +615,6 @@ class MessageProcessor:
|
|||||||
device_action_key=device_action_key,
|
device_action_key=device_action_key,
|
||||||
status=JobStatus.QUEUE,
|
status=JobStatus.QUEUE,
|
||||||
start_time=time.time(),
|
start_time=time.time(),
|
||||||
always_free=action_always_free,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 添加到设备管理器
|
# 添加到设备管理器
|
||||||
@@ -734,13 +626,13 @@ class MessageProcessor:
|
|||||||
await self._send_action_state_response(
|
await self._send_action_state_response(
|
||||||
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
||||||
)
|
)
|
||||||
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
|
logger.info(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||||
else:
|
else:
|
||||||
# 需要排队
|
# 需要排队
|
||||||
await self._send_action_state_response(
|
await self._send_action_state_response(
|
||||||
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
||||||
)
|
)
|
||||||
logger.trace(f"[MessageProcessor] Job {job_log} queued")
|
logger.info(f"[MessageProcessor] Job {job_log} queued")
|
||||||
|
|
||||||
# 通知QueueProcessor有新的队列更新
|
# 通知QueueProcessor有新的队列更新
|
||||||
if self.queue_processor:
|
if self.queue_processor:
|
||||||
@@ -749,8 +641,6 @@ class MessageProcessor:
|
|||||||
async def _handle_job_start(self, data: Dict[str, Any]):
|
async def _handle_job_start(self, data: Dict[str, Any]):
|
||||||
"""处理job_start消息"""
|
"""处理job_start消息"""
|
||||||
try:
|
try:
|
||||||
if not data.get("sample_material"):
|
|
||||||
data["sample_material"] = {}
|
|
||||||
req = JobAddReq(**data)
|
req = JobAddReq(**data)
|
||||||
|
|
||||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||||
@@ -782,7 +672,6 @@ class MessageProcessor:
|
|||||||
queue_item,
|
queue_item,
|
||||||
action_type=req.action_type,
|
action_type=req.action_type,
|
||||||
action_kwargs=req.action_args,
|
action_kwargs=req.action_args,
|
||||||
sample_material=req.sample_material,
|
|
||||||
server_info=req.server_info,
|
server_info=req.server_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -947,7 +836,9 @@ class MessageProcessor:
|
|||||||
device_action_groups[key_add] = []
|
device_action_groups[key_add] = []
|
||||||
device_action_groups[key_add].append(item["uuid"])
|
device_action_groups[key_add].append(item["uuid"])
|
||||||
|
|
||||||
logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}")
|
logger.info(
|
||||||
|
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 正常update
|
# 正常update
|
||||||
key = (device_id, "update")
|
key = (device_id, "update")
|
||||||
@@ -961,13 +852,11 @@ class MessageProcessor:
|
|||||||
device_action_groups[key] = []
|
device_action_groups[key] = []
|
||||||
device_action_groups[key].append(item["uuid"])
|
device_action_groups[key].append(item["uuid"])
|
||||||
|
|
||||||
logger.trace(
|
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||||
f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 为每个(device_id, action)创建独立的更新线程
|
# 为每个(device_id, action)创建独立的更新线程
|
||||||
for (device_id, actual_action), items in device_action_groups.items():
|
for (device_id, actual_action), items in device_action_groups.items():
|
||||||
logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
|
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
|
||||||
|
|
||||||
def _notify_resource_tree(dev_id, act, item_list):
|
def _notify_resource_tree(dev_id, act, item_list):
|
||||||
try:
|
try:
|
||||||
@@ -999,81 +888,6 @@ class MessageProcessor:
|
|||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
async def _handle_device_manage(self, device_list: list[ResourceDictType], action: str):
|
|
||||||
"""Handle add_device / remove_device from LabGo server."""
|
|
||||||
if not device_list:
|
|
||||||
return
|
|
||||||
|
|
||||||
for item in device_list:
|
|
||||||
target_node_id = item.get("target_node_id", "host_node")
|
|
||||||
|
|
||||||
def _notify(target_id: str, act: str, cfg: ResourceDictType):
|
|
||||||
try:
|
|
||||||
host_node = HostNode.get_instance(timeout=5)
|
|
||||||
if not host_node:
|
|
||||||
logger.error(f"[DeviceManage] HostNode not available for {act}_device")
|
|
||||||
return
|
|
||||||
success = host_node.notify_device_manage(target_id, act, cfg)
|
|
||||||
if success:
|
|
||||||
logger.info(f"[DeviceManage] {act}_device completed on {target_id}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"[DeviceManage] {act}_device failed on {target_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[DeviceManage] Error in {act}_device: {e}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=_notify,
|
|
||||||
args=(target_node_id, action, item),
|
|
||||||
daemon=True,
|
|
||||||
name=f"DeviceManage-{action}-{item.get('id', '')}",
|
|
||||||
)
|
|
||||||
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")
|
|
||||||
|
|
||||||
# 发送确认消息
|
|
||||||
self.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(
|
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
|
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
|
||||||
):
|
):
|
||||||
@@ -1145,7 +959,6 @@ class QueueProcessor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止队列处理线程"""
|
"""停止队列处理线程"""
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.queue_update_event.set() # 立即唤醒等待中的线程
|
|
||||||
if self.thread and self.thread.is_alive():
|
if self.thread and self.thread.is_alive():
|
||||||
self.thread.join(timeout=2)
|
self.thread.join(timeout=2)
|
||||||
logger.info("[QueueProcessor] Stopped")
|
logger.info("[QueueProcessor] Stopped")
|
||||||
@@ -1246,11 +1059,6 @@ class QueueProcessor:
|
|||||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
||||||
|
|
||||||
for job_info in 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 = {
|
message = {
|
||||||
"action": "report_action_state",
|
"action": "report_action_state",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1266,7 +1074,7 @@ class QueueProcessor:
|
|||||||
success = self.message_processor.send_message(message)
|
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)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
if success:
|
if success:
|
||||||
logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
||||||
|
|
||||||
@@ -1289,7 +1097,7 @@ class QueueProcessor:
|
|||||||
job_info.action_name,
|
job_info.action_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||||
|
|
||||||
# 结束任务,获取下一个可执行的任务
|
# 结束任务,获取下一个可执行的任务
|
||||||
next_job = self.device_manager.end_job(job_id)
|
next_job = self.device_manager.end_job(job_id)
|
||||||
@@ -1309,8 +1117,8 @@ class QueueProcessor:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
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)
|
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")
|
logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||||
|
|
||||||
# 立即触发下一轮状态检查
|
# 立即触发下一轮状态检查
|
||||||
self.notify_queue_update()
|
self.notify_queue_update()
|
||||||
@@ -1399,8 +1207,8 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||||
# send_handler 每100ms检查一次队列,等300ms足以让消息发出
|
# 给一点时间让消息发送出去
|
||||||
time.sleep(0.3)
|
time.sleep(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||||
|
|
||||||
@@ -1432,7 +1240,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||||
|
|
||||||
def publish_job_status(
|
def publish_job_status(
|
||||||
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
||||||
@@ -1452,7 +1260,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError):
|
||||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||||
|
|
||||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
logger.info(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||||
|
|
||||||
# 通知队列处理器job完成(包括timeout的job)
|
# 通知队列处理器job完成(包括timeout的job)
|
||||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||||
@@ -1474,7 +1282,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
|
|
||||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||||
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
logger.debug(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||||
|
|
||||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||||
"""发送ping消息"""
|
"""发送ping消息"""
|
||||||
@@ -1505,59 +1313,17 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
|
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
|
||||||
|
|
||||||
def publish_host_ready(self) -> None:
|
def publish_host_ready(self) -> None:
|
||||||
"""发布host_node ready信号,包含设备和动作信息"""
|
"""发布host_node ready信号"""
|
||||||
if self.is_disabled or not self.is_connected():
|
if self.is_disabled or not self.is_connected():
|
||||||
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
|
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
|
||||||
return
|
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 = {
|
message = {
|
||||||
"action": "host_node_ready",
|
"action": "host_node_ready",
|
||||||
"data": {
|
"data": {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"machine_name": machine_name,
|
|
||||||
"devices": devices,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
logger.info(f"[WebSocketClient] Host node ready signal published with {len(devices)} devices")
|
logger.info("[WebSocketClient] Host node ready signal published")
|
||||||
|
|||||||
@@ -95,29 +95,8 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
|||||||
return total_volume
|
return total_volume
|
||||||
|
|
||||||
|
|
||||||
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
def is_integrated_pump(node_name):
|
||||||
"""
|
return "pump" in node_name and "valve" in node_name
|
||||||
判断是否为泵阀一体设备
|
|
||||||
"""
|
|
||||||
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):
|
def find_connected_pump(G, valve_node):
|
||||||
@@ -207,9 +186,7 @@ def build_pump_valve_maps(G, pump_backbone):
|
|||||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||||
|
|
||||||
for node in filtered_backbone:
|
for node in filtered_backbone:
|
||||||
node_data = G.nodes.get(node, {})
|
if is_integrated_pump(G.nodes[node]["class"]):
|
||||||
node_class = node_data.get("class", "") or ""
|
|
||||||
if is_integrated_pump(node_class, node):
|
|
||||||
pumps_from_node[node] = node
|
pumps_from_node[node] = node
|
||||||
valve_from_node[node] = node
|
valve_from_node[node] = node
|
||||||
debug_print(f" - 集成泵-阀: {node}")
|
debug_print(f" - 集成泵-阀: {node}")
|
||||||
|
|||||||
@@ -16,15 +16,11 @@ class BasicConfig:
|
|||||||
upload_registry = False
|
upload_registry = False
|
||||||
machine_name = "undefined"
|
machine_name = "undefined"
|
||||||
vis_2d_enable = False
|
vis_2d_enable = False
|
||||||
no_update_feedback = False
|
|
||||||
enable_resource_load = True
|
enable_resource_load = True
|
||||||
communication_protocol = "websocket"
|
communication_protocol = "websocket"
|
||||||
startup_json_path = None # 填写绝对路径
|
startup_json_path = None # 填写绝对路径
|
||||||
disable_browser = False # 禁止浏览器自动打开
|
disable_browser = False # 禁止浏览器自动打开
|
||||||
port = 8002 # 本地HTTP服务
|
port = 8002 # 本地HTTP服务
|
||||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
|
||||||
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
|
||||||
extra_resource = False # 是否加载lab_开头的额外资源
|
|
||||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||||
|
|
||||||
@@ -41,7 +37,7 @@ class BasicConfig:
|
|||||||
class WSConfig:
|
class WSConfig:
|
||||||
reconnect_interval = 5 # 重连间隔(秒)
|
reconnect_interval = 5 # 重连间隔(秒)
|
||||||
max_reconnect_attempts = 999 # 最大重连次数
|
max_reconnect_attempts = 999 # 最大重连次数
|
||||||
ping_interval = 20 # ping间隔(秒)
|
ping_interval = 30 # ping间隔(秒)
|
||||||
|
|
||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
@@ -147,5 +143,5 @@ def load_config(config_path=None):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
exit(1)
|
exit(1)
|
||||||
else:
|
else:
|
||||||
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
|
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
|
||||||
load_config(config_path)
|
load_config(config_path)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Coin Cell Assembly Workstation
|
|||||||
"""
|
"""
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
|
||||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker
|
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||||
from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo
|
from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo
|
||||||
from unilabos.device_comms.workstation_communication import (
|
from unilabos.device_comms.workstation_communication import (
|
||||||
WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication
|
WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication
|
||||||
@@ -61,7 +61,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
# 创建资源跟踪器(如果没有提供)
|
# 创建资源跟踪器(如果没有提供)
|
||||||
if resource_tracker is None:
|
if resource_tracker is None:
|
||||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker
|
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||||
resource_tracker = DeviceNodeResourceTracker()
|
resource_tracker = DeviceNodeResourceTracker()
|
||||||
|
|
||||||
# 初始化基类
|
# 初始化基类
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ class Base(ABC):
|
|||||||
self._type = typ
|
self._type = typ
|
||||||
self._data_type = data_type
|
self._data_type = data_type
|
||||||
self._node: Optional[Node] = None
|
self._node: Optional[Node] = None
|
||||||
|
|
||||||
def _get_node(self) -> Node:
|
def _get_node(self) -> Node:
|
||||||
if self._node is None:
|
if self._node is None:
|
||||||
try:
|
try:
|
||||||
@@ -66,7 +66,7 @@ class Base(ABC):
|
|||||||
# 直接以字符串形式处理
|
# 直接以字符串形式处理
|
||||||
if isinstance(nid, str):
|
if isinstance(nid, str):
|
||||||
nid = nid.strip()
|
nid = nid.strip()
|
||||||
|
|
||||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||||
# 提取括号内的内容
|
# 提取括号内的内容
|
||||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
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]:
|
def read(self) -> Tuple[Any, bool]:
|
||||||
"""读取节点值,返回(值, 是否出错)"""
|
"""读取节点值,返回(值, 是否出错)"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""写入节点值,返回是否出错"""
|
"""写入节点值,返回是否出错"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self) -> NodeType:
|
def type(self) -> NodeType:
|
||||||
return self._type
|
return self._type
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def node_id(self) -> str:
|
def node_id(self) -> str:
|
||||||
return self._node_id
|
return self._node_id
|
||||||
@@ -210,15 +210,15 @@ class Method(Base):
|
|||||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||||
self._parent_node_id = parent_node_id
|
self._parent_node_id = parent_node_id
|
||||||
self._parent_node = None
|
self._parent_node = None
|
||||||
|
|
||||||
def _get_parent_node(self) -> Node:
|
def _get_parent_node(self) -> Node:
|
||||||
if self._parent_node is None:
|
if self._parent_node is None:
|
||||||
try:
|
try:
|
||||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||||
import re
|
import re
|
||||||
|
|
||||||
nid = self._parent_node_id
|
nid = self._parent_node_id
|
||||||
|
|
||||||
# 如果已经是 NodeId 对象,直接使用
|
# 如果已经是 NodeId 对象,直接使用
|
||||||
try:
|
try:
|
||||||
from opcua.ua import NodeId as UaNodeId
|
from opcua.ua import NodeId as UaNodeId
|
||||||
@@ -227,16 +227,16 @@ class Method(Base):
|
|||||||
return self._parent_node
|
return self._parent_node
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 字符串处理
|
# 字符串处理
|
||||||
if isinstance(nid, str):
|
if isinstance(nid, str):
|
||||||
nid = nid.strip()
|
nid = nid.strip()
|
||||||
|
|
||||||
# 处理包含类名的格式
|
# 处理包含类名的格式
|
||||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||||
if match_wrapped:
|
if match_wrapped:
|
||||||
nid = match_wrapped.group(2).strip()
|
nid = match_wrapped.group(2).strip()
|
||||||
|
|
||||||
# 常见短格式
|
# 常见短格式
|
||||||
if re.match(r'^ns=\d+;[is]=', nid):
|
if re.match(r'^ns=\d+;[is]=', nid):
|
||||||
self._parent_node = self._client.get_node(nid)
|
self._parent_node = self._client.get_node(nid)
|
||||||
@@ -271,7 +271,7 @@ class Method(Base):
|
|||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""方法节点不支持写入操作"""
|
"""方法节点不支持写入操作"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def call(self, *args) -> Tuple[Any, bool]:
|
def call(self, *args) -> Tuple[Any, bool]:
|
||||||
"""调用方法,返回(返回值, 是否出错)"""
|
"""调用方法,返回(返回值, 是否出错)"""
|
||||||
try:
|
try:
|
||||||
@@ -285,7 +285,7 @@ class Method(Base):
|
|||||||
class Object(Base):
|
class Object(Base):
|
||||||
def __init__(self, client: Client, name: str, node_id: str):
|
def __init__(self, client: Client, name: str, node_id: str):
|
||||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||||
|
|
||||||
def read(self) -> Tuple[Any, bool]:
|
def read(self) -> Tuple[Any, bool]:
|
||||||
"""对象节点不支持直接读取操作"""
|
"""对象节点不支持直接读取操作"""
|
||||||
return None, True
|
return None, True
|
||||||
@@ -293,7 +293,7 @@ class Object(Base):
|
|||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""对象节点不支持直接写入操作"""
|
"""对象节点不支持直接写入操作"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_children(self) -> Tuple[List[Node], bool]:
|
def get_children(self) -> Tuple[List[Node], bool]:
|
||||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||||
try:
|
try:
|
||||||
@@ -301,4 +301,4 @@ class Object(Base):
|
|||||||
return children, False
|
return children, False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||||
return [], True
|
return [], True
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import inspect
|
import inspect
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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、语言优先
|
|
||||||
本准则同时具有中文版本和英文版本,如果英文版本和中文版本有冲突,以中文版本为准。
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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.
|
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ from pylabrobot.resources import (
|
|||||||
import copy
|
import copy
|
||||||
from unilabos_msgs.msg import Resource
|
from unilabos_msgs.msg import Resource
|
||||||
|
|
||||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker # type: ignore
|
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class LiquidHandlerBiomek:
|
class LiquidHandlerBiomek:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,10 @@ from rclpy.node import Node
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
|
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
|
||||||
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", registry_name: str = "lh_joint_publisher", **kwargs):
|
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
registry_name=registry_name,
|
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings={},
|
action_value_mappings={},
|
||||||
hardware_interface={},
|
hardware_interface={},
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class VirtualSampleDemo:
|
|
||||||
"""虚拟样品追踪演示设备,提供两种典型返回模式:
|
|
||||||
- measure_samples: 等长输入输出 (前端按 index 自动对齐)
|
|
||||||
- split_and_measure: 输出比输入长,附带 samples 列标注归属
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
|
||||||
if device_id is None and "id" in kwargs:
|
|
||||||
device_id = kwargs.pop("id")
|
|
||||||
if config is None and "config" in kwargs:
|
|
||||||
config = kwargs.pop("config")
|
|
||||||
|
|
||||||
self.device_id = device_id or "unknown_sample_demo"
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}")
|
|
||||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Action 1: 等长输入输出,无 samples 列
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]:
|
|
||||||
"""模拟光度测量。absorbance = concentration * 0.05 + noise
|
|
||||||
|
|
||||||
入参和出参 list 长度相等,前端按 index 自动对齐。
|
|
||||||
"""
|
|
||||||
self.logger.info(f"measure_samples: concentrations={concentrations}")
|
|
||||||
absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations]
|
|
||||||
return {"concentrations": concentrations, "absorbance": absorbance}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Action 2: 输出比输入长,带 samples 列
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]:
|
|
||||||
"""将每个样品均分为 split_count 份后逐份测量。
|
|
||||||
|
|
||||||
返回的 list 长度 = len(volumes) * split_count,
|
|
||||||
附带 samples 列标注每行属于第几个输入样品 (0-based index)。
|
|
||||||
"""
|
|
||||||
self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}")
|
|
||||||
out_volumes: List[float] = []
|
|
||||||
readings: List[float] = []
|
|
||||||
samples: List[int] = []
|
|
||||||
|
|
||||||
for idx, vol in enumerate(volumes):
|
|
||||||
split_vol = round(vol / split_count, 2)
|
|
||||||
for _ in range(split_count):
|
|
||||||
out_volumes.append(split_vol)
|
|
||||||
readings.append(round(random.uniform(0.1, 1.0), 4))
|
|
||||||
samples.append(idx)
|
|
||||||
|
|
||||||
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Action 3: 入参和出参都带 samples 列(不等长)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]:
|
|
||||||
"""对 split_and_measure 的输出做二次分析。
|
|
||||||
|
|
||||||
入参 readings/samples 长度相同但 > 原始样品数,
|
|
||||||
出参同样带 samples 列,长度与入参一致。
|
|
||||||
"""
|
|
||||||
self.logger.info(f"analyze_readings: readings={readings}, samples={samples}")
|
|
||||||
scores: List[float] = []
|
|
||||||
passed: List[bool] = []
|
|
||||||
threshold = 0.4
|
|
||||||
|
|
||||||
for r in readings:
|
|
||||||
score = round(r * 100 + random.gauss(0, 2), 2)
|
|
||||||
scores.append(score)
|
|
||||||
passed.append(r >= threshold)
|
|
||||||
|
|
||||||
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 状态属性
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@property
|
|
||||||
def status(self) -> str:
|
|
||||||
return self.data.get("status", "Idle")
|
|
||||||
@@ -15,35 +15,35 @@ class VirtualPumpMode(Enum):
|
|||||||
|
|
||||||
class VirtualTransferPump:
|
class VirtualTransferPump:
|
||||||
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
|
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
|
||||||
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
|
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
|
||||||
"""
|
"""
|
||||||
初始化虚拟转移泵
|
初始化虚拟转移泵
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_id: 设备ID
|
device_id: 设备ID
|
||||||
config: 配置字典,包含max_volume, port等参数
|
config: 配置字典,包含max_volume, port等参数
|
||||||
**kwargs: 其他参数,确保兼容性
|
**kwargs: 其他参数,确保兼容性
|
||||||
"""
|
"""
|
||||||
self.device_id = device_id or "virtual_transfer_pump"
|
self.device_id = device_id or "virtual_transfer_pump"
|
||||||
|
|
||||||
# 从config或kwargs中获取参数,确保类型正确
|
# 从config或kwargs中获取参数,确保类型正确
|
||||||
if config:
|
if config:
|
||||||
self.max_volume = float(config.get("max_volume", 25.0))
|
self.max_volume = float(config.get('max_volume', 25.0))
|
||||||
self.port = config.get("port", "VIRTUAL")
|
self.port = config.get('port', 'VIRTUAL')
|
||||||
else:
|
else:
|
||||||
self.max_volume = float(kwargs.get("max_volume", 25.0))
|
self.max_volume = float(kwargs.get('max_volume', 25.0))
|
||||||
self.port = kwargs.get("port", "VIRTUAL")
|
self.port = kwargs.get('port', 'VIRTUAL')
|
||||||
|
|
||||||
self._transfer_rate = float(kwargs.get("transfer_rate", 0))
|
self._transfer_rate = float(kwargs.get('transfer_rate', 0))
|
||||||
self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
|
self.mode = kwargs.get('mode', VirtualPumpMode.Normal)
|
||||||
|
|
||||||
# 状态变量 - 确保都是正确类型
|
# 状态变量 - 确保都是正确类型
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self._position = 0.0 # float
|
self._position = 0.0 # float
|
||||||
self._max_velocity = 5.0 # float
|
self._max_velocity = 5.0 # float
|
||||||
self._current_volume = 0.0 # float
|
self._current_volume = 0.0 # float
|
||||||
|
|
||||||
# 🚀 新增:快速模式设置 - 大幅缩短执行时间
|
# 🚀 新增:快速模式设置 - 大幅缩短执行时间
|
||||||
@@ -52,16 +52,14 @@ class VirtualTransferPump:
|
|||||||
self._fast_dispense_time = 1.0 # 快速喷射时间(秒)
|
self._fast_dispense_time = 1.0 # 快速喷射时间(秒)
|
||||||
|
|
||||||
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
|
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
|
||||||
|
|
||||||
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
|
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
|
||||||
print(
|
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s")
|
||||||
f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s"
|
|
||||||
)
|
|
||||||
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
|
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
|
||||||
|
|
||||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
self._ros_node = ros_node
|
self._ros_node = ros_node
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""初始化虚拟泵 🚀"""
|
"""初始化虚拟泵 🚀"""
|
||||||
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id} ✨")
|
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id} ✨")
|
||||||
@@ -70,33 +68,33 @@ class VirtualTransferPump:
|
|||||||
self._current_volume = 0.0
|
self._current_volume = 0.0
|
||||||
self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰")
|
self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def cleanup(self) -> bool:
|
async def cleanup(self) -> bool:
|
||||||
"""清理虚拟泵 🧹"""
|
"""清理虚拟泵 🧹"""
|
||||||
self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚")
|
self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚")
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤")
|
self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 基本属性
|
# 基本属性
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def position(self) -> float:
|
def position(self) -> float:
|
||||||
"""当前柱塞位置 (ml) 📍"""
|
"""当前柱塞位置 (ml) 📍"""
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_volume(self) -> float:
|
def current_volume(self) -> float:
|
||||||
"""当前注射器中的体积 (ml) 💧"""
|
"""当前注射器中的体积 (ml) 💧"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_velocity(self) -> float:
|
def max_velocity(self) -> float:
|
||||||
return self._max_velocity
|
return self._max_velocity
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def transfer_rate(self) -> float:
|
def transfer_rate(self) -> float:
|
||||||
return self._transfer_rate
|
return self._transfer_rate
|
||||||
@@ -105,17 +103,17 @@ class VirtualTransferPump:
|
|||||||
"""设置最大速度 (ml/s) 🌊"""
|
"""设置最大速度 (ml/s) 🌊"""
|
||||||
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内
|
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内
|
||||||
self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s")
|
self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s")
|
||||||
|
|
||||||
def get_status(self) -> str:
|
def get_status(self) -> str:
|
||||||
"""获取泵状态 📋"""
|
"""获取泵状态 📋"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
async def _simulate_operation(self, duration: float):
|
async def _simulate_operation(self, duration: float):
|
||||||
"""模拟操作延时 ⏱️"""
|
"""模拟操作延时 ⏱️"""
|
||||||
self._status = "Busy"
|
self._status = "Busy"
|
||||||
await self._ros_node.sleep(duration)
|
await self._ros_node.sleep(duration)
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
|
|
||||||
def _calculate_duration(self, volume: float, velocity: float = None) -> float:
|
def _calculate_duration(self, volume: float, velocity: float = None) -> float:
|
||||||
"""
|
"""
|
||||||
计算操作持续时间 ⏰
|
计算操作持续时间 ⏰
|
||||||
@@ -123,10 +121,10 @@ class VirtualTransferPump:
|
|||||||
"""
|
"""
|
||||||
if velocity is None:
|
if velocity is None:
|
||||||
velocity = self._max_velocity
|
velocity = self._max_velocity
|
||||||
|
|
||||||
# 📊 计算理论时间(用于日志显示)
|
# 📊 计算理论时间(用于日志显示)
|
||||||
theoretical_duration = abs(volume) / velocity
|
theoretical_duration = abs(volume) / velocity
|
||||||
|
|
||||||
# 🚀 如果启用快速模式,使用固定的快速时间
|
# 🚀 如果启用快速模式,使用固定的快速时间
|
||||||
if self._fast_mode:
|
if self._fast_mode:
|
||||||
# 根据操作类型选择快速时间
|
# 根据操作类型选择快速时间
|
||||||
@@ -134,13 +132,13 @@ class VirtualTransferPump:
|
|||||||
actual_duration = self._fast_move_time
|
actual_duration = self._fast_move_time
|
||||||
else: # 很小的操作
|
else: # 很小的操作
|
||||||
actual_duration = 0.5
|
actual_duration = 0.5
|
||||||
|
|
||||||
self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s")
|
self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s")
|
||||||
return actual_duration
|
return actual_duration
|
||||||
else:
|
else:
|
||||||
# 正常模式使用理论时间
|
# 正常模式使用理论时间
|
||||||
return theoretical_duration
|
return theoretical_duration
|
||||||
|
|
||||||
def _calculate_display_duration(self, volume: float, velocity: float = None) -> float:
|
def _calculate_display_duration(self, volume: float, velocity: float = None) -> float:
|
||||||
"""
|
"""
|
||||||
计算显示用的持续时间(用于日志) 📊
|
计算显示用的持续时间(用于日志) 📊
|
||||||
@@ -149,16 +147,16 @@ class VirtualTransferPump:
|
|||||||
if velocity is None:
|
if velocity is None:
|
||||||
velocity = self._max_velocity
|
velocity = self._max_velocity
|
||||||
return abs(volume) / velocity
|
return abs(volume) / velocity
|
||||||
|
|
||||||
# 新的set_position方法 - 专门用于SetPumpPosition动作
|
# 新的set_position方法 - 专门用于SetPumpPosition动作
|
||||||
async def set_position(self, position: float, max_velocity: float = None):
|
async def set_position(self, position: float, max_velocity: float = None):
|
||||||
"""
|
"""
|
||||||
移动到绝对位置 - 专门用于SetPumpPosition动作 🎯
|
移动到绝对位置 - 专门用于SetPumpPosition动作 🎯
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
position (float): 目标位置 (ml)
|
position (float): 目标位置 (ml)
|
||||||
max_velocity (float): 移动速度 (ml/s)
|
max_velocity (float): 移动速度 (ml/s)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: 符合SetPumpPosition.action定义的结果
|
dict: 符合SetPumpPosition.action定义的结果
|
||||||
"""
|
"""
|
||||||
@@ -166,19 +164,19 @@ class VirtualTransferPump:
|
|||||||
# 验证并转换参数
|
# 验证并转换参数
|
||||||
target_position = float(position)
|
target_position = float(position)
|
||||||
velocity = float(max_velocity) if max_velocity is not None else self._max_velocity
|
velocity = float(max_velocity) if max_velocity is not None else self._max_velocity
|
||||||
|
|
||||||
# 限制位置在有效范围内
|
# 限制位置在有效范围内
|
||||||
target_position = max(0.0, min(float(self.max_volume), target_position))
|
target_position = max(0.0, min(float(self.max_volume), target_position))
|
||||||
|
|
||||||
# 计算移动距离
|
# 计算移动距离
|
||||||
volume_to_move = abs(target_position - self._position)
|
volume_to_move = abs(target_position - self._position)
|
||||||
|
|
||||||
# 📊 计算显示用的时间(用于日志)
|
# 📊 计算显示用的时间(用于日志)
|
||||||
display_duration = self._calculate_display_duration(volume_to_move, velocity)
|
display_duration = self._calculate_display_duration(volume_to_move, velocity)
|
||||||
|
|
||||||
# ⚡ 计算实际执行时间(快速模式)
|
# ⚡ 计算实际执行时间(快速模式)
|
||||||
actual_duration = self._calculate_duration(volume_to_move, velocity)
|
actual_duration = self._calculate_duration(volume_to_move, velocity)
|
||||||
|
|
||||||
# 🎯 确定操作类型和emoji
|
# 🎯 确定操作类型和emoji
|
||||||
if target_position > self._position:
|
if target_position > self._position:
|
||||||
operation_type = "吸液"
|
operation_type = "吸液"
|
||||||
@@ -189,34 +187,28 @@ class VirtualTransferPump:
|
|||||||
else:
|
else:
|
||||||
operation_type = "保持"
|
operation_type = "保持"
|
||||||
operation_emoji = "📍"
|
operation_emoji = "📍"
|
||||||
|
|
||||||
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
|
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
|
||||||
self.logger.info(
|
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)")
|
||||||
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
|
|
||||||
)
|
|
||||||
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
|
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
|
||||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||||
|
|
||||||
if self._fast_mode:
|
if self._fast_mode:
|
||||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||||
|
|
||||||
# 🚀 模拟移动过程
|
# 🚀 模拟移动过程
|
||||||
if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度
|
if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度
|
||||||
start_position = self._position
|
start_position = self._position
|
||||||
steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数
|
steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数
|
||||||
step_duration = actual_duration / steps
|
step_duration = actual_duration / steps
|
||||||
|
|
||||||
self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}")
|
self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}")
|
||||||
|
|
||||||
for i in range(steps + 1):
|
for i in range(steps + 1):
|
||||||
# 计算当前位置和进度
|
# 计算当前位置和进度
|
||||||
progress = (i / steps) * 100 if steps > 0 else 100
|
progress = (i / steps) * 100 if steps > 0 else 100
|
||||||
current_pos = (
|
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position
|
||||||
start_position + (target_position - start_position) * (i / steps)
|
|
||||||
if steps > 0
|
|
||||||
else target_position
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新状态
|
# 更新状态
|
||||||
if i < steps:
|
if i < steps:
|
||||||
self._status = f"{operation_type}中"
|
self._status = f"{operation_type}中"
|
||||||
@@ -224,10 +216,10 @@ class VirtualTransferPump:
|
|||||||
else:
|
else:
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
status_emoji = "✅"
|
status_emoji = "✅"
|
||||||
|
|
||||||
self._position = current_pos
|
self._position = current_pos
|
||||||
self._current_volume = current_pos
|
self._current_volume = current_pos
|
||||||
|
|
||||||
# 显示进度(每25%或最后一步)
|
# 显示进度(每25%或最后一步)
|
||||||
if i == 0:
|
if i == 0:
|
||||||
self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%")
|
self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%")
|
||||||
@@ -235,7 +227,7 @@ class VirtualTransferPump:
|
|||||||
self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%")
|
self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%")
|
||||||
elif i == steps:
|
elif i == steps:
|
||||||
self.logger.info(f" ✅ {operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL")
|
self.logger.info(f" ✅ {operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL")
|
||||||
|
|
||||||
# 等待一小步时间
|
# 等待一小步时间
|
||||||
if i < steps and step_duration > 0:
|
if i < steps and step_duration > 0:
|
||||||
await self._ros_node.sleep(step_duration)
|
await self._ros_node.sleep(step_duration)
|
||||||
@@ -244,27 +236,25 @@ class VirtualTransferPump:
|
|||||||
self._position = target_position
|
self._position = target_position
|
||||||
self._current_volume = target_position
|
self._current_volume = target_position
|
||||||
self.logger.info(f" 📍 微调完成: {target_position:.2f}mL")
|
self.logger.info(f" 📍 微调完成: {target_position:.2f}mL")
|
||||||
|
|
||||||
# 确保最终位置准确
|
# 确保最终位置准确
|
||||||
self._position = target_position
|
self._position = target_position
|
||||||
self._current_volume = target_position
|
self._current_volume = target_position
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
|
|
||||||
# 📊 最终状态日志
|
# 📊 最终状态日志
|
||||||
if volume_to_move > 0.01:
|
if volume_to_move > 0.01:
|
||||||
self.logger.info(
|
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||||
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 返回符合action定义的结果
|
# 返回符合action定义的结果
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
||||||
"final_position": self._position,
|
"final_position": self._position,
|
||||||
"final_volume": self._current_volume,
|
"final_volume": self._current_volume,
|
||||||
"operation_type": operation_type,
|
"operation_type": operation_type
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"❌ 设置位置失败: {str(e)}"
|
error_msg = f"❌ 设置位置失败: {str(e)}"
|
||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
@@ -272,136 +262,134 @@ class VirtualTransferPump:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": error_msg,
|
"message": error_msg,
|
||||||
"final_position": self._position,
|
"final_position": self._position,
|
||||||
"final_volume": self._current_volume,
|
"final_volume": self._current_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
# 其他泵操作方法
|
# 其他泵操作方法
|
||||||
async def pull_plunger(self, volume: float, velocity: float = None):
|
async def pull_plunger(self, volume: float, velocity: float = None):
|
||||||
"""
|
"""
|
||||||
拉取柱塞(吸液) 📥
|
拉取柱塞(吸液) 📥
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
volume (float): 要拉取的体积 (ml)
|
volume (float): 要拉取的体积 (ml)
|
||||||
velocity (float): 拉取速度 (ml/s)
|
velocity (float): 拉取速度 (ml/s)
|
||||||
"""
|
"""
|
||||||
new_position = min(self.max_volume, self._position + volume)
|
new_position = min(self.max_volume, self._position + volume)
|
||||||
actual_volume = new_position - self._position
|
actual_volume = new_position - self._position
|
||||||
|
|
||||||
if actual_volume <= 0:
|
if actual_volume <= 0:
|
||||||
self.logger.warning("⚠️ 无法吸液 - 已达到最大容量")
|
self.logger.warning("⚠️ 无法吸液 - 已达到最大容量")
|
||||||
return
|
return
|
||||||
|
|
||||||
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
||||||
actual_duration = self._calculate_duration(actual_volume, velocity)
|
actual_duration = self._calculate_duration(actual_volume, velocity)
|
||||||
|
|
||||||
self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL")
|
self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL")
|
||||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
||||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||||
|
|
||||||
if self._fast_mode:
|
if self._fast_mode:
|
||||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||||
|
|
||||||
await self._simulate_operation(actual_duration)
|
await self._simulate_operation(actual_duration)
|
||||||
|
|
||||||
self._position = new_position
|
self._position = new_position
|
||||||
self._current_volume = new_position
|
self._current_volume = new_position
|
||||||
|
|
||||||
self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||||
|
|
||||||
async def push_plunger(self, volume: float, velocity: float = None):
|
async def push_plunger(self, volume: float, velocity: float = None):
|
||||||
"""
|
"""
|
||||||
推出柱塞(排液) 📤
|
推出柱塞(排液) 📤
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
volume (float): 要推出的体积 (ml)
|
volume (float): 要推出的体积 (ml)
|
||||||
velocity (float): 推出速度 (ml/s)
|
velocity (float): 推出速度 (ml/s)
|
||||||
"""
|
"""
|
||||||
new_position = max(0, self._position - volume)
|
new_position = max(0, self._position - volume)
|
||||||
actual_volume = self._position - new_position
|
actual_volume = self._position - new_position
|
||||||
|
|
||||||
if actual_volume <= 0:
|
if actual_volume <= 0:
|
||||||
self.logger.warning("⚠️ 无法排液 - 已达到最小容量")
|
self.logger.warning("⚠️ 无法排液 - 已达到最小容量")
|
||||||
return
|
return
|
||||||
|
|
||||||
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
||||||
actual_duration = self._calculate_duration(actual_volume, velocity)
|
actual_duration = self._calculate_duration(actual_volume, velocity)
|
||||||
|
|
||||||
self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL")
|
self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL")
|
||||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
||||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||||
|
|
||||||
if self._fast_mode:
|
if self._fast_mode:
|
||||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||||
|
|
||||||
await self._simulate_operation(actual_duration)
|
await self._simulate_operation(actual_duration)
|
||||||
|
|
||||||
self._position = new_position
|
self._position = new_position
|
||||||
self._current_volume = new_position
|
self._current_volume = new_position
|
||||||
|
|
||||||
self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||||
|
|
||||||
# 便捷操作方法
|
# 便捷操作方法
|
||||||
async def aspirate(self, volume: float, velocity: float = None):
|
async def aspirate(self, volume: float, velocity: float = None):
|
||||||
"""吸液操作 📥"""
|
"""吸液操作 📥"""
|
||||||
await self.pull_plunger(volume, velocity)
|
await self.pull_plunger(volume, velocity)
|
||||||
|
|
||||||
async def dispense(self, volume: float, velocity: float = None):
|
async def dispense(self, volume: float, velocity: float = None):
|
||||||
"""排液操作 📤"""
|
"""排液操作 📤"""
|
||||||
await self.push_plunger(volume, velocity)
|
await self.push_plunger(volume, velocity)
|
||||||
|
|
||||||
async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None):
|
async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None):
|
||||||
"""转移操作(先吸后排) 🔄"""
|
"""转移操作(先吸后排) 🔄"""
|
||||||
self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL")
|
self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL")
|
||||||
|
|
||||||
# 吸液
|
# 吸液
|
||||||
await self.aspirate(volume, aspirate_velocity)
|
await self.aspirate(volume, aspirate_velocity)
|
||||||
|
|
||||||
# 短暂停顿
|
# 短暂停顿
|
||||||
self.logger.debug("⏸️ 短暂停顿...")
|
self.logger.debug("⏸️ 短暂停顿...")
|
||||||
await self._ros_node.sleep(0.1)
|
await self._ros_node.sleep(0.1)
|
||||||
|
|
||||||
# 排液
|
# 排液
|
||||||
await self.dispense(volume, dispense_velocity)
|
await self.dispense(volume, dispense_velocity)
|
||||||
|
|
||||||
async def empty_syringe(self, velocity: float = None):
|
async def empty_syringe(self, velocity: float = None):
|
||||||
"""清空注射器"""
|
"""清空注射器"""
|
||||||
await self.set_position(0, velocity)
|
await self.set_position(0, velocity)
|
||||||
|
|
||||||
async def fill_syringe(self, velocity: float = None):
|
async def fill_syringe(self, velocity: float = None):
|
||||||
"""充满注射器"""
|
"""充满注射器"""
|
||||||
await self.set_position(self.max_volume, velocity)
|
await self.set_position(self.max_volume, velocity)
|
||||||
|
|
||||||
async def stop_operation(self):
|
async def stop_operation(self):
|
||||||
"""停止当前操作"""
|
"""停止当前操作"""
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self.logger.info("Operation stopped")
|
self.logger.info("Operation stopped")
|
||||||
|
|
||||||
# 状态查询方法
|
# 状态查询方法
|
||||||
def get_position(self) -> float:
|
def get_position(self) -> float:
|
||||||
"""获取当前位置"""
|
"""获取当前位置"""
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
def get_current_volume(self) -> float:
|
def get_current_volume(self) -> float:
|
||||||
"""获取当前体积"""
|
"""获取当前体积"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
def get_remaining_capacity(self) -> float:
|
def get_remaining_capacity(self) -> float:
|
||||||
"""获取剩余容量"""
|
"""获取剩余容量"""
|
||||||
return self.max_volume - self._current_volume
|
return self.max_volume - self._current_volume
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
"""检查是否为空"""
|
"""检查是否为空"""
|
||||||
return self._current_volume <= 0.01 # 允许小量误差
|
return self._current_volume <= 0.01 # 允许小量误差
|
||||||
|
|
||||||
def is_full(self) -> bool:
|
def is_full(self) -> bool:
|
||||||
"""检查是否已满"""
|
"""检查是否已满"""
|
||||||
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
|
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
||||||
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
@@ -410,20 +398,20 @@ class VirtualTransferPump:
|
|||||||
async def demo():
|
async def demo():
|
||||||
"""虚拟泵使用示例"""
|
"""虚拟泵使用示例"""
|
||||||
pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0})
|
pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0})
|
||||||
|
|
||||||
await pump.initialize()
|
await pump.initialize()
|
||||||
|
|
||||||
print(f"Initial state: {pump}")
|
print(f"Initial state: {pump}")
|
||||||
|
|
||||||
# 测试set_position方法
|
# 测试set_position方法
|
||||||
result = await pump.set_position(10.0, max_velocity=2.0)
|
result = await pump.set_position(10.0, max_velocity=2.0)
|
||||||
print(f"Set position result: {result}")
|
print(f"Set position result: {result}")
|
||||||
print(f"After setting position to 10ml: {pump}")
|
print(f"After setting position to 10ml: {pump}")
|
||||||
|
|
||||||
# 吸液测试
|
# 吸液测试
|
||||||
await pump.aspirate(5.0, velocity=2.0)
|
await pump.aspirate(5.0, velocity=2.0)
|
||||||
print(f"After aspirating 5ml: {pump}")
|
print(f"After aspirating 5ml: {pump}")
|
||||||
|
|
||||||
# 清空测试
|
# 清空测试
|
||||||
result = await pump.set_position(0.0)
|
result = await pump.set_position(0.0)
|
||||||
print(f"Empty result: {result}")
|
print(f"Empty result: {result}")
|
||||||
|
|||||||
@@ -1,874 +0,0 @@
|
|||||||
"""
|
|
||||||
Virtual Workbench Device - 模拟工作台设备
|
|
||||||
包含:
|
|
||||||
- 1个机械臂 (每次操作3s, 独占锁)
|
|
||||||
- 3个加热台 (每次加热10s, 可并行)
|
|
||||||
|
|
||||||
工作流程:
|
|
||||||
1. A1-A5 物料同时启动, 竞争机械臂
|
|
||||||
2. 机械臂将物料移动到空闲加热台
|
|
||||||
3. 加热完成后, 机械臂将物料移动到C1-C5
|
|
||||||
|
|
||||||
注意: 调用来自线程池, 使用 threading.Lock 进行同步
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
from threading import Lock, RLock
|
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
|
||||||
|
|
||||||
from unilabos.registry.decorators import (
|
|
||||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
|
||||||
)
|
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
|
||||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
|
||||||
|
|
||||||
|
|
||||||
# ============ TypedDict 返回类型定义 ============
|
|
||||||
|
|
||||||
|
|
||||||
class MoveToHeatingStationResult(TypedDict):
|
|
||||||
"""move_to_heating_station 返回类型"""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
station_id: int
|
|
||||||
material_id: str
|
|
||||||
material_number: int
|
|
||||||
message: str
|
|
||||||
unilabos_samples: List[LabSample]
|
|
||||||
|
|
||||||
|
|
||||||
class StartHeatingResult(TypedDict):
|
|
||||||
"""start_heating 返回类型"""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
station_id: int
|
|
||||||
material_id: str
|
|
||||||
material_number: int
|
|
||||||
message: str
|
|
||||||
unilabos_samples: List[LabSample]
|
|
||||||
|
|
||||||
|
|
||||||
class MoveToOutputResult(TypedDict):
|
|
||||||
"""move_to_output 返回类型"""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
station_id: int
|
|
||||||
material_id: str
|
|
||||||
output_position: str
|
|
||||||
message: str
|
|
||||||
unilabos_samples: List[LabSample]
|
|
||||||
|
|
||||||
|
|
||||||
class PrepareMaterialsResult(TypedDict):
|
|
||||||
"""prepare_materials 返回类型 - 批量准备物料"""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
count: int
|
|
||||||
material_1: int # 物料编号1
|
|
||||||
material_2: int # 物料编号2
|
|
||||||
material_3: int # 物料编号3
|
|
||||||
material_4: int # 物料编号4
|
|
||||||
material_5: int # 物料编号5
|
|
||||||
message: str
|
|
||||||
unilabos_samples: List[LabSample]
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 状态枚举 ============
|
|
||||||
|
|
||||||
|
|
||||||
class HeatingStationState(Enum):
|
|
||||||
"""加热台状态枚举"""
|
|
||||||
|
|
||||||
IDLE = "idle" # 空闲
|
|
||||||
OCCUPIED = "occupied" # 已放置物料, 等待加热
|
|
||||||
HEATING = "heating" # 加热中
|
|
||||||
COMPLETED = "completed" # 加热完成, 等待取走
|
|
||||||
|
|
||||||
|
|
||||||
class ArmState(Enum):
|
|
||||||
"""机械臂状态枚举"""
|
|
||||||
|
|
||||||
IDLE = "idle" # 空闲
|
|
||||||
BUSY = "busy" # 工作中
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HeatingStation:
|
|
||||||
"""加热台数据结构"""
|
|
||||||
|
|
||||||
station_id: int
|
|
||||||
state: HeatingStationState = HeatingStationState.IDLE
|
|
||||||
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
|
|
||||||
material_number: Optional[int] = None # 物料编号 (1-5)
|
|
||||||
heating_start_time: Optional[float] = None
|
|
||||||
heating_progress: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@device(
|
|
||||||
id="virtual_workbench",
|
|
||||||
category=["virtual_device"],
|
|
||||||
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
|
||||||
)
|
|
||||||
class VirtualWorkbench:
|
|
||||||
"""
|
|
||||||
Virtual Workbench Device - 虚拟工作台设备
|
|
||||||
|
|
||||||
模拟一个包含1个机械臂和3个加热台的工作站
|
|
||||||
- 机械臂操作耗时3秒, 同一时间只能执行一个操作
|
|
||||||
- 加热台加热耗时10秒, 3个加热台可并行工作
|
|
||||||
|
|
||||||
工作流:
|
|
||||||
1. 物料A1-A5并发启动(线程池), 竞争机械臂使用权
|
|
||||||
2. 获取机械臂后, 查找空闲加热台
|
|
||||||
3. 机械臂将物料放入加热台, 开始加热
|
|
||||||
4. 加热完成后, 机械臂将物料移动到目标位置Cn
|
|
||||||
"""
|
|
||||||
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
|
||||||
|
|
||||||
# 配置常量
|
|
||||||
ARM_OPERATION_TIME: float = 2 # 机械臂操作时间(秒)
|
|
||||||
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
|
||||||
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
|
||||||
# 处理可能的不同调用方式
|
|
||||||
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 "virtual_workbench"
|
|
||||||
self.config = config or {}
|
|
||||||
|
|
||||||
self.logger = logging.getLogger(f"VirtualWorkbench.{self.device_id}")
|
|
||||||
self.data: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
# 从config中获取可配置参数
|
|
||||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
|
|
||||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
|
||||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
|
||||||
|
|
||||||
# 机械臂状态和锁
|
|
||||||
self._arm_lock = Lock()
|
|
||||||
self._arm_state = ArmState.IDLE
|
|
||||||
self._arm_current_task: Optional[str] = None
|
|
||||||
|
|
||||||
# 加热台状态
|
|
||||||
self._heating_stations: Dict[int, HeatingStation] = {
|
|
||||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
|
||||||
}
|
|
||||||
self._stations_lock = RLock()
|
|
||||||
|
|
||||||
# 任务追踪
|
|
||||||
self._active_tasks: Dict[str, Dict[str, Any]] = {}
|
|
||||||
self._tasks_lock = Lock()
|
|
||||||
|
|
||||||
# 处理其他kwargs参数
|
|
||||||
skip_keys = {"arm_operation_time", "heating_time", "num_heating_stations"}
|
|
||||||
for key, value in kwargs.items():
|
|
||||||
if key not in skip_keys and not hasattr(self, key):
|
|
||||||
setattr(self, key, value)
|
|
||||||
|
|
||||||
self.logger.info(f"=== 虚拟工作台 {self.device_id} 已创建 ===")
|
|
||||||
self.logger.info(
|
|
||||||
f"机械臂操作时间: {self.ARM_OPERATION_TIME}s | "
|
|
||||||
f"加热时间: {self.HEATING_TIME}s | "
|
|
||||||
f"加热台数量: {self.NUM_HEATING_STATIONS}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
|
||||||
"""ROS节点初始化后回调"""
|
|
||||||
self._ros_node = ros_node
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""初始化虚拟工作台"""
|
|
||||||
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
for station in self._heating_stations.values():
|
|
||||||
station.state = HeatingStationState.IDLE
|
|
||||||
station.current_material = None
|
|
||||||
station.material_number = None
|
|
||||||
station.heating_progress = 0.0
|
|
||||||
|
|
||||||
self.data.update(
|
|
||||||
{
|
|
||||||
"status": "Ready",
|
|
||||||
"arm_state": ArmState.IDLE.value,
|
|
||||||
"arm_current_task": None,
|
|
||||||
"heating_stations": self._get_stations_status(),
|
|
||||||
"active_tasks_count": 0,
|
|
||||||
"message": "工作台就绪",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
|
|
||||||
return True
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def cleanup(self) -> bool:
|
|
||||||
"""清理虚拟工作台"""
|
|
||||||
self.logger.info(f"清理虚拟工作台 {self.device_id}")
|
|
||||||
|
|
||||||
self._arm_state = ArmState.IDLE
|
|
||||||
self._arm_current_task = None
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
self._heating_stations.clear()
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
self._active_tasks.clear()
|
|
||||||
|
|
||||||
self.data.update(
|
|
||||||
{
|
|
||||||
"status": "Offline",
|
|
||||||
"arm_state": ArmState.IDLE.value,
|
|
||||||
"heating_stations": {},
|
|
||||||
"message": "工作台已关闭",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _get_stations_status(self) -> Dict[int, Dict[str, Any]]:
|
|
||||||
"""获取所有加热台状态"""
|
|
||||||
with self._stations_lock:
|
|
||||||
return {
|
|
||||||
station_id: {
|
|
||||||
"state": station.state.value,
|
|
||||||
"current_material": station.current_material,
|
|
||||||
"material_number": station.material_number,
|
|
||||||
"heating_progress": station.heating_progress,
|
|
||||||
}
|
|
||||||
for station_id, station in self._heating_stations.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def _update_data_status(self, message: Optional[str] = None):
|
|
||||||
"""更新状态数据"""
|
|
||||||
self.data.update(
|
|
||||||
{
|
|
||||||
"arm_state": self._arm_state.value,
|
|
||||||
"arm_current_task": self._arm_current_task,
|
|
||||||
"heating_stations": self._get_stations_status(),
|
|
||||||
"active_tasks_count": len(self._active_tasks),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if message:
|
|
||||||
self.data["message"] = message
|
|
||||||
|
|
||||||
def _find_available_heating_station(self) -> Optional[int]:
|
|
||||||
"""查找空闲的加热台"""
|
|
||||||
with self._stations_lock:
|
|
||||||
for station_id, station in self._heating_stations.items():
|
|
||||||
if station.state == HeatingStationState.IDLE:
|
|
||||||
return station_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _acquire_arm(self, task_description: str) -> bool:
|
|
||||||
"""获取机械臂使用权(阻塞直到获取)"""
|
|
||||||
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
|
||||||
self._arm_lock.acquire()
|
|
||||||
self._arm_state = ArmState.BUSY
|
|
||||||
self._arm_current_task = task_description
|
|
||||||
self._update_data_status(f"机械臂执行: {task_description}")
|
|
||||||
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _release_arm(self):
|
|
||||||
"""释放机械臂"""
|
|
||||||
task = self._arm_current_task
|
|
||||||
self._arm_state = ArmState.IDLE
|
|
||||||
self._arm_current_task = None
|
|
||||||
self._arm_lock.release()
|
|
||||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
|
||||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
|
||||||
|
|
||||||
@action(
|
|
||||||
auto_prefix=True,
|
|
||||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
|
||||||
handles=[
|
|
||||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
|
||||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
|
||||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
|
||||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
|
||||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
|
||||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def prepare_materials(
|
|
||||||
self,
|
|
||||||
sample_uuids: SampleUUIDsType,
|
|
||||||
count: int = 5,
|
|
||||||
) -> PrepareMaterialsResult:
|
|
||||||
"""
|
|
||||||
批量准备物料 - 虚拟起始节点
|
|
||||||
|
|
||||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
|
||||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
|
||||||
"""
|
|
||||||
materials = [i for i in range(1, count + 1)]
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"[准备物料] 生成 {count} 个物料: A1-A{count} -> material_1~material_{count}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"count": count,
|
|
||||||
"material_1": materials[0] if len(materials) > 0 else 0,
|
|
||||||
"material_2": materials[1] if len(materials) > 1 else 0,
|
|
||||||
"material_3": materials[2] if len(materials) > 2 else 0,
|
|
||||||
"material_4": materials[3] if len(materials) > 3 else 0,
|
|
||||||
"material_5": materials[4] if len(materials) > 4 else 0,
|
|
||||||
"message": f"已准备 {count} 个物料: A1-A{count}",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
@action(
|
|
||||||
auto_prefix=True,
|
|
||||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
|
||||||
handles=[
|
|
||||||
ActionInputHandle(key="material_input", data_type="workbench_material",
|
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
|
||||||
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def move_to_heating_station(
|
|
||||||
self,
|
|
||||||
sample_uuids: SampleUUIDsType,
|
|
||||||
material_number: int,
|
|
||||||
) -> MoveToHeatingStationResult:
|
|
||||||
"""
|
|
||||||
将物料从An位置移动到加热台
|
|
||||||
|
|
||||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
|
||||||
"""
|
|
||||||
material_id = f"A{material_number}"
|
|
||||||
task_desc = f"移动{material_id}到加热台"
|
|
||||||
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
self._active_tasks[material_id] = {
|
|
||||||
"status": "waiting_for_arm",
|
|
||||||
"start_time": time.time(),
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._tasks_lock:
|
|
||||||
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
|
||||||
self._acquire_arm(task_desc)
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
self._active_tasks[material_id]["status"] = "finding_station"
|
|
||||||
station_id = None
|
|
||||||
|
|
||||||
while station_id is None:
|
|
||||||
station_id = self._find_available_heating_station()
|
|
||||||
if station_id is None:
|
|
||||||
self.logger.info(f"[{material_id}] 没有空闲加热台, 等待中...")
|
|
||||||
self._release_arm()
|
|
||||||
time.sleep(0.5)
|
|
||||||
self._acquire_arm(task_desc)
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
|
||||||
self._heating_stations[station_id].current_material = material_id
|
|
||||||
self._heating_stations[station_id].material_number = material_number
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
self._active_tasks[material_id]["status"] = "arm_moving"
|
|
||||||
self._active_tasks[material_id]["assigned_station"] = station_id
|
|
||||||
self.logger.info(f"[{material_id}] 机械臂正在移动到加热台{station_id}...")
|
|
||||||
|
|
||||||
time.sleep(self.ARM_OPERATION_TIME)
|
|
||||||
|
|
||||||
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
|
||||||
self.logger.info(
|
|
||||||
f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._release_arm()
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
self._active_tasks[material_id]["status"] = "placed_on_station"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": material_id,
|
|
||||||
"material_number": material_number,
|
|
||||||
"message": f"{material_id}已成功移动到加热台{station_id}",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"[{material_id}] 移动失败: {str(e)}")
|
|
||||||
if self._arm_lock.locked():
|
|
||||||
self._release_arm()
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"station_id": -1,
|
|
||||||
"material_id": material_id,
|
|
||||||
"material_number": material_number,
|
|
||||||
"message": f"移动失败: {str(e)}",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
@action(
|
|
||||||
auto_prefix=True,
|
|
||||||
always_free=True,
|
|
||||||
description="启动指定加热台的加热程序",
|
|
||||||
handles=[
|
|
||||||
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
|
||||||
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
|
||||||
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
|
||||||
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
|
||||||
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def start_heating(
|
|
||||||
self,
|
|
||||||
sample_uuids: SampleUUIDsType,
|
|
||||||
station_id: int,
|
|
||||||
material_number: int,
|
|
||||||
) -> StartHeatingResult:
|
|
||||||
"""
|
|
||||||
启动指定加热台的加热程序
|
|
||||||
"""
|
|
||||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
|
||||||
|
|
||||||
if station_id not in self._heating_stations:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": "",
|
|
||||||
"material_number": material_number,
|
|
||||||
"message": f"无效的加热台ID: {station_id}",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations[station_id]
|
|
||||||
|
|
||||||
if station.current_material is None:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": "",
|
|
||||||
"material_number": material_number,
|
|
||||||
"message": f"加热台{station_id}上没有物料",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if station.state == HeatingStationState.HEATING:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": station.current_material,
|
|
||||||
"material_number": material_number,
|
|
||||||
"message": f"加热台{station_id}已经在加热中",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
material_id = station.current_material
|
|
||||||
|
|
||||||
station.state = HeatingStationState.HEATING
|
|
||||||
station.heating_start_time = time.time()
|
|
||||||
station.heating_progress = 0.0
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
if material_id in self._active_tasks:
|
|
||||||
self._active_tasks[material_id]["status"] = "heating"
|
|
||||||
|
|
||||||
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
heating_list = [
|
|
||||||
f"加热台{sid}:{s.current_material}"
|
|
||||||
for sid, s in self._heating_stations.items()
|
|
||||||
if s.state == HeatingStationState.HEATING and s.current_material
|
|
||||||
]
|
|
||||||
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
last_countdown_log = start_time
|
|
||||||
while True:
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
remaining = max(0.0, self.HEATING_TIME - elapsed)
|
|
||||||
progress = min(100.0, (elapsed / self.HEATING_TIME) * 100)
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
self._heating_stations[station_id].heating_progress = progress
|
|
||||||
|
|
||||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
|
||||||
|
|
||||||
if time.time() - last_countdown_log >= 5.0:
|
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
|
||||||
last_countdown_log = time.time()
|
|
||||||
|
|
||||||
if elapsed >= self.HEATING_TIME:
|
|
||||||
break
|
|
||||||
|
|
||||||
time.sleep(1.0)
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
|
||||||
self._heating_stations[station_id].heating_progress = 100.0
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
if material_id in self._active_tasks:
|
|
||||||
self._active_tasks[material_id]["status"] = "heating_completed"
|
|
||||||
|
|
||||||
self._update_data_status(f"加热台{station_id}加热完成")
|
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": material_id,
|
|
||||||
"material_number": material_number,
|
|
||||||
"message": f"加热台{station_id}加热完成",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
@action(
|
|
||||||
auto_prefix=True,
|
|
||||||
description="将物料从加热台移动到输出位置Cn",
|
|
||||||
handles=[
|
|
||||||
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
|
||||||
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def move_to_output(
|
|
||||||
self,
|
|
||||||
sample_uuids: SampleUUIDsType,
|
|
||||||
station_id: int,
|
|
||||||
material_number: int,
|
|
||||||
) -> MoveToOutputResult:
|
|
||||||
"""
|
|
||||||
将物料从加热台移动到输出位置Cn
|
|
||||||
"""
|
|
||||||
output_number = material_number
|
|
||||||
|
|
||||||
if station_id not in self._heating_stations:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": "",
|
|
||||||
"output_position": f"C{output_number}",
|
|
||||||
"message": f"无效的加热台ID: {station_id}",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations[station_id]
|
|
||||||
material_id = station.current_material
|
|
||||||
|
|
||||||
if material_id is None:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": "",
|
|
||||||
"output_position": f"C{output_number}",
|
|
||||||
"message": f"加热台{station_id}上没有物料",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if station.state != HeatingStationState.COMPLETED:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": material_id,
|
|
||||||
"output_position": f"C{output_number}",
|
|
||||||
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
output_position = f"C{output_number}"
|
|
||||||
task_desc = f"从加热台{station_id}移动{material_id}到{output_position}"
|
|
||||||
self.logger.info(f"[任务] {task_desc}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._tasks_lock:
|
|
||||||
if material_id in self._active_tasks:
|
|
||||||
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
|
||||||
|
|
||||||
self._acquire_arm(task_desc)
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
if material_id in self._active_tasks:
|
|
||||||
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}..."
|
|
||||||
)
|
|
||||||
time.sleep(self.ARM_OPERATION_TIME)
|
|
||||||
|
|
||||||
with self._stations_lock:
|
|
||||||
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
|
||||||
self._heating_stations[station_id].current_material = None
|
|
||||||
self._heating_stations[station_id].material_number = None
|
|
||||||
self._heating_stations[station_id].heating_progress = 0.0
|
|
||||||
self._heating_stations[station_id].heating_start_time = None
|
|
||||||
|
|
||||||
self._release_arm()
|
|
||||||
|
|
||||||
with self._tasks_lock:
|
|
||||||
if material_id in self._active_tasks:
|
|
||||||
self._active_tasks[material_id]["status"] = "completed"
|
|
||||||
self._active_tasks[material_id]["end_time"] = time.time()
|
|
||||||
|
|
||||||
self._update_data_status(f"{material_id}已移动到{output_position}")
|
|
||||||
self.logger.info(
|
|
||||||
f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": material_id,
|
|
||||||
"output_position": output_position,
|
|
||||||
"message": f"{material_id}已成功移动到{output_position}",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str)
|
|
||||||
else (content.serialize() if content is not None else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"移动到输出位置失败: {str(e)}")
|
|
||||||
if self._arm_lock.locked():
|
|
||||||
self._release_arm()
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"station_id": station_id,
|
|
||||||
"material_id": "",
|
|
||||||
"output_position": output_position,
|
|
||||||
"message": f"移动失败: {str(e)}",
|
|
||||||
"unilabos_samples": [
|
|
||||||
LabSample(
|
|
||||||
sample_uuid=sample_uuid,
|
|
||||||
oss_path="",
|
|
||||||
extra=(
|
|
||||||
{"material_uuid": content}
|
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sample_uuid, content in sample_uuids.items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============ 状态属性 ============
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
|
||||||
return self.data.get("status", "Unknown")
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def arm_state(self) -> str:
|
|
||||||
return self._arm_state.value
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def arm_current_task(self) -> str:
|
|
||||||
return self._arm_current_task or ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_1_state(self) -> str:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(1)
|
|
||||||
return station.state.value if station else "unknown"
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_1_material(self) -> str:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(1)
|
|
||||||
return station.current_material or "" if station else ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_1_progress(self) -> float:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(1)
|
|
||||||
return station.heating_progress if station else 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_2_state(self) -> str:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(2)
|
|
||||||
return station.state.value if station else "unknown"
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_2_material(self) -> str:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(2)
|
|
||||||
return station.current_material or "" if station else ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_2_progress(self) -> float:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(2)
|
|
||||||
return station.heating_progress if station else 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_3_state(self) -> str:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(3)
|
|
||||||
return station.state.value if station else "unknown"
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_3_material(self) -> str:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(3)
|
|
||||||
return station.current_material or "" if station else ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def heating_station_3_progress(self) -> float:
|
|
||||||
with self._stations_lock:
|
|
||||||
station = self._heating_stations.get(3)
|
|
||||||
return station.heating_progress if station else 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def active_tasks_count(self) -> int:
|
|
||||||
with self._tasks_lock:
|
|
||||||
return len(self._active_tasks)
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def message(self) -> str:
|
|
||||||
return self.data.get("message", "")
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# 工作站抽象基类物料系统架构说明
|
# 工作站抽象基类物料系统架构说明
|
||||||
|
|
||||||
|
## 设计理念
|
||||||
|
|
||||||
|
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
||||||
|
|
||||||
### 核心原则
|
### 核心原则
|
||||||
|
|
||||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
# Bioyond Cell 工作站 - 多订单返回示例
|
|
||||||
|
|
||||||
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
|
|
||||||
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
修改后的 `create_orders` 函数现在会:
|
|
||||||
|
|
||||||
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
|
|
||||||
2. **逐个等待完成**:遍历所有 orderCode,调用 `wait_for_order_finish` 等待每个订单完成
|
|
||||||
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
|
|
||||||
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
|
|
||||||
|
|
||||||
## 返回格式
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "all_completed",
|
|
||||||
"total_orders": 3,
|
|
||||||
"reports": [
|
|
||||||
{
|
|
||||||
"token": "",
|
|
||||||
"request_time": "2025-12-24T15:32:09.2148671+08:00",
|
|
||||||
"data": {
|
|
||||||
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
|
|
||||||
"orderCode": "BSO2025122400024",
|
|
||||||
"orderName": "DP20251224001",
|
|
||||||
"status": "30",
|
|
||||||
"workflowStatus": "completed",
|
|
||||||
"usedMaterials": [...]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"token": "",
|
|
||||||
"request_time": "2025-12-24T15:32:09.9999039+08:00",
|
|
||||||
"data": {
|
|
||||||
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
|
|
||||||
"orderCode": "BSO2025122400025",
|
|
||||||
"orderName": "DP20251224002",
|
|
||||||
"status": "30",
|
|
||||||
"workflowStatus": "completed",
|
|
||||||
"usedMaterials": [...]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"token": "",
|
|
||||||
"request_time": "2025-12-24T15:34:00.4139986+08:00",
|
|
||||||
"data": {
|
|
||||||
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
|
|
||||||
"orderCode": "BSO2025122400026",
|
|
||||||
"orderName": "DP20251224003",
|
|
||||||
"status": "30",
|
|
||||||
"workflowStatus": "completed",
|
|
||||||
"usedMaterials": [...]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"original_response": {...}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 调用 create_orders
|
|
||||||
result = workstation.create_orders("20251224.xlsx")
|
|
||||||
|
|
||||||
# 访问返回数据
|
|
||||||
print(f"总订单数: {result['total_orders']}")
|
|
||||||
print(f"状态: {result['status']}")
|
|
||||||
|
|
||||||
# 遍历所有订单的报文
|
|
||||||
for i, report in enumerate(result['reports'], 1):
|
|
||||||
order_data = report.get('data', {})
|
|
||||||
print(f"\n订单 {i}:")
|
|
||||||
print(f" orderCode: {order_data.get('orderCode')}")
|
|
||||||
print(f" orderName: {order_data.get('orderName')}")
|
|
||||||
print(f" status: {order_data.get('status')}")
|
|
||||||
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 控制台输出示例
|
|
||||||
|
|
||||||
```
|
|
||||||
[create_orders] 即将提交订单数量: 3
|
|
||||||
[create_orders] 接口返回: {...}
|
|
||||||
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
|
|
||||||
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
|
|
||||||
[create_orders] ✓ 订单 BSO2025122400024 完成
|
|
||||||
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
|
|
||||||
[create_orders] ✓ 订单 BSO2025122400025 完成
|
|
||||||
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
|
|
||||||
[create_orders] ✓ 订单 BSO2025122400026 完成
|
|
||||||
[create_orders] 所有订单已完成,共收集 3 个报文
|
|
||||||
实验记录本========================create_orders========================
|
|
||||||
返回报文数量: 3
|
|
||||||
报文 1: orderCode=BSO2025122400024, status=30
|
|
||||||
报文 2: orderCode=BSO2025122400025, status=30
|
|
||||||
报文 3: orderCode=BSO2025122400026, status=30
|
|
||||||
========================
|
|
||||||
```
|
|
||||||
|
|
||||||
## 关键改进
|
|
||||||
|
|
||||||
1. ✅ **等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
|
|
||||||
2. ✅ **收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
|
|
||||||
3. ✅ **详细日志**:清晰显示正在等待哪个订单,以及完成情况
|
|
||||||
4. ✅ **错误处理**:即使某个订单失败,也会记录其状态信息
|
|
||||||
5. ✅ **统一格式**:返回的 JSON 格式便于后续处理和分析
|
|
||||||
Binary file not shown.
@@ -1,204 +0,0 @@
|
|||||||
# BioyondCellWorkstation JSON 配置迁移经验总结
|
|
||||||
|
|
||||||
**日期**: 2026-01-13
|
|
||||||
**目的**: 从 `config.py` 迁移到 JSON 配置文件
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 问题背景
|
|
||||||
|
|
||||||
原系统通过 `config.py` 管理配置,导致:
|
|
||||||
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
|
|
||||||
2. 配置分散在代码中,不便于管理
|
|
||||||
3. 无法通过 JSON 统一配置所有参数
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 解决方案:嵌套配置结构
|
|
||||||
|
|
||||||
### JSON 结构设计
|
|
||||||
|
|
||||||
**正确示例** (嵌套在 `config` 中):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [{
|
|
||||||
"id": "bioyond_cell_workstation",
|
|
||||||
"config": {
|
|
||||||
"deck": {...},
|
|
||||||
"protocol_type": [],
|
|
||||||
"bioyond_config": {
|
|
||||||
"api_host": "http://172.16.11.219:44388",
|
|
||||||
"api_key": "8A819E5C",
|
|
||||||
"timeout": 30,
|
|
||||||
"HTTP_host": "172.16.11.206",
|
|
||||||
"HTTP_port": 8080,
|
|
||||||
"debug_mode": false,
|
|
||||||
"material_type_mappings": {...},
|
|
||||||
"warehouse_mapping": {...},
|
|
||||||
"solid_liquid_mappings": {...}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"data": {}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键点**:
|
|
||||||
- ✅ `bioyond_config` 放在 `config` 中(会传递到 `__init__`)
|
|
||||||
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Python 代码适配
|
|
||||||
|
|
||||||
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
|
|
||||||
|
|
||||||
**文件**: `bioyond_cell_workstation.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
bioyond_config: 从 JSON 加载的配置字典
|
|
||||||
deck: Deck 配置
|
|
||||||
protocol_type: 协议类型
|
|
||||||
"""
|
|
||||||
# 验证配置
|
|
||||||
if bioyond_config is None:
|
|
||||||
raise ValueError("需要 bioyond_config 参数")
|
|
||||||
|
|
||||||
# 保存配置
|
|
||||||
self.bioyond_config = bioyond_config
|
|
||||||
|
|
||||||
# 设置 HTTP 服务去重标志
|
|
||||||
self.bioyond_config["_disable_auto_http_service"] = True
|
|
||||||
|
|
||||||
# 调用父类
|
|
||||||
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 替换全局变量引用
|
|
||||||
|
|
||||||
**修改前**(使用全局变量):
|
|
||||||
```python
|
|
||||||
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
|
|
||||||
|
|
||||||
def create_sample(self, board_type, ...):
|
|
||||||
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
|
||||||
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改后**(从配置读取):
|
|
||||||
```python
|
|
||||||
def create_sample(self, board_type, ...):
|
|
||||||
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
|
||||||
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 修复父类配置访问
|
|
||||||
|
|
||||||
在 `station.py` 中安全访问配置默认值:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 修改前(会 KeyError)
|
|
||||||
self._http_service_config = {
|
|
||||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
|
|
||||||
}
|
|
||||||
|
|
||||||
# 修改后(安全访问)
|
|
||||||
self._http_service_config = {
|
|
||||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见陷阱
|
|
||||||
|
|
||||||
### ❌ 错误1:将配置放在 `data` 字段
|
|
||||||
```json
|
|
||||||
"config": {"deck": {...}},
|
|
||||||
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ 错误2:扁平化配置(已废弃方案)
|
|
||||||
虽然扁平化也能工作,但不推荐:
|
|
||||||
```json
|
|
||||||
"config": {
|
|
||||||
"deck": {...},
|
|
||||||
"api_host": "...", // ❌ 不够清晰
|
|
||||||
"api_key": "...",
|
|
||||||
"HTTP_host": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ 错误3:忘记替换全局变量引用
|
|
||||||
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 云端同步注意事项
|
|
||||||
|
|
||||||
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
|
|
||||||
- 首次上传时确保 JSON 完整
|
|
||||||
- 或使用新的 `ak/sk` 避免旧配置干扰
|
|
||||||
- 调试时可暂时移除 `--upload_registry` 参数
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 验证清单
|
|
||||||
|
|
||||||
启动成功后应看到:
|
|
||||||
```
|
|
||||||
✅ 从 JSON 配置加载 bioyond_config 成功
|
|
||||||
API Host: http://...
|
|
||||||
HTTP Service: ...
|
|
||||||
✅ BioyondCellWorkstation 初始化完成
|
|
||||||
Loaded ResourceTreeSet with ... nodes
|
|
||||||
```
|
|
||||||
|
|
||||||
运行时不应出现:
|
|
||||||
- ❌ `NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
|
|
||||||
- ❌ `KeyError: 'http_service_host'`
|
|
||||||
- ❌ `bioyond_config 缺少必需参数`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 调试经验
|
|
||||||
|
|
||||||
1. **添加调试日志**查看参数传递链路:
|
|
||||||
- `graphio.py`: JSON 加载后的 config 内容
|
|
||||||
- `initialize_device.py`: `device_config.res_content.config` 的键
|
|
||||||
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
|
|
||||||
|
|
||||||
2. **config vs data 区别**:
|
|
||||||
- `config`: 初始化参数,传递给 `__init__`
|
|
||||||
- `data`: 运行时状态,不传递给 `__init__`
|
|
||||||
|
|
||||||
3. **参数名必须匹配**:
|
|
||||||
- JSON 中的键名必须与 `__init__` 参数名完全一致
|
|
||||||
|
|
||||||
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 修改文件清单
|
|
||||||
|
|
||||||
| 文件 | 修改内容 |
|
|
||||||
|------|----------|
|
|
||||||
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
|
|
||||||
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
|
|
||||||
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 参考代码位置
|
|
||||||
|
|
||||||
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
|
|
||||||
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
|
|
||||||
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
|
|
||||||
- HTTP 服务配置: `station.py` L629-L634
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
# BioyondCell 配置迁移修改总结
|
|
||||||
|
|
||||||
**日期**: 2026-01-13
|
|
||||||
**目标**: 从 `config.py` 完全迁移到 JSON 配置,消除所有全局变量依赖
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 修改概览
|
|
||||||
|
|
||||||
本次修改完成了 BioyondCell 模块从 Python 配置文件到 JSON 配置的完整迁移,并清理了所有对 `config.py` 全局变量的依赖。
|
|
||||||
|
|
||||||
### 核心成果
|
|
||||||
|
|
||||||
- ✅ 完全移除对 `config.py` 的导入依赖
|
|
||||||
- ✅ 使用嵌套 JSON 结构 `config.bioyond_config`
|
|
||||||
- ✅ 修复 7 处 `bioyond_cell_workstation.py` 中的全局变量引用
|
|
||||||
- ✅ 修复 3 处其他文件中的全局变量引用
|
|
||||||
- ✅ HTTP 服务去重机制完善
|
|
||||||
- ✅ 系统成功启动并正常运行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 修改文件清单
|
|
||||||
|
|
||||||
### 1. JSON 配置文件
|
|
||||||
|
|
||||||
**文件**: `yibin_electrolyte_config.json`
|
|
||||||
|
|
||||||
**修改**:
|
|
||||||
- 采用嵌套结构将所有配置放在 `config.bioyond_config` 中
|
|
||||||
- 包含:`api_host`, `api_key`, `HTTP_host`, `HTTP_port`, `material_type_mappings`, `warehouse_mapping`, `solid_liquid_mappings` 等
|
|
||||||
|
|
||||||
**示例结构**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [{
|
|
||||||
"id": "bioyond_cell_workstation",
|
|
||||||
"config": {
|
|
||||||
"deck": {...},
|
|
||||||
"protocol_type": [],
|
|
||||||
"bioyond_config": {
|
|
||||||
"api_host": "http://172.16.11.219:44388",
|
|
||||||
"api_key": "8A819E5C",
|
|
||||||
"HTTP_host": "172.16.11.206",
|
|
||||||
"HTTP_port": 8080,
|
|
||||||
"material_type_mappings": {...},
|
|
||||||
"warehouse_mapping": {...},
|
|
||||||
"solid_liquid_mappings": {...}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. bioyond_cell_workstation.py
|
|
||||||
|
|
||||||
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
|
||||||
|
|
||||||
#### 修改 A: `__init__` 方法签名 (L39-99)
|
|
||||||
|
|
||||||
**修改前**:
|
|
||||||
```python
|
|
||||||
def __init__(self, deck=None, protocol_type=None, **kwargs):
|
|
||||||
# 从 kwargs 收集配置字段
|
|
||||||
self.bioyond_config = {}
|
|
||||||
for field in bioyond_field_names:
|
|
||||||
if field in kwargs:
|
|
||||||
self.bioyond_config[field] = kwargs.pop(field)
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改后**:
|
|
||||||
```python
|
|
||||||
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
|
||||||
"""直接接收 bioyond_config 参数"""
|
|
||||||
if bioyond_config is None:
|
|
||||||
raise ValueError("需要 bioyond_config 参数")
|
|
||||||
|
|
||||||
self.bioyond_config = bioyond_config
|
|
||||||
|
|
||||||
# 设置 HTTP 服务去重标志
|
|
||||||
self.bioyond_config["_disable_auto_http_service"] = True
|
|
||||||
|
|
||||||
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 修改 B: 替换全局变量引用 (7 处)
|
|
||||||
|
|
||||||
| 位置 | 原代码 | 修改后 |
|
|
||||||
|------|--------|--------|
|
|
||||||
| L2005 | `MATERIAL_TYPE_MAPPINGS[board_type][1]` | `self.bioyond_config['material_type_mappings'][board_type][1]` |
|
|
||||||
| L2006 | `MATERIAL_TYPE_MAPPINGS[bottle_type][1]` | `self.bioyond_config['material_type_mappings'][bottle_type][1]` |
|
|
||||||
| L2009 | `WAREHOUSE_MAPPING` | `self.bioyond_config['warehouse_mapping']` |
|
|
||||||
| L2013 | `WAREHOUSE_MAPPING[warehouse_name]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]` |
|
|
||||||
| L2017 | `WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"]` |
|
|
||||||
| L1863 | `SOLID_LIQUID_MAPPINGS.get(material_name)` | `self.bioyond_config.get('solid_liquid_mappings', {}).get(material_name)` |
|
|
||||||
| L1966, L1976 | `MATERIAL_TYPE_MAPPINGS.items()` | `self.bioyond_config['material_type_mappings'].items()` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. station.py
|
|
||||||
|
|
||||||
**位置**: `unilabos/devices/workstation/bioyond_studio/station.py`
|
|
||||||
|
|
||||||
#### 修改 A: 删除 config 导入 (L26-28)
|
|
||||||
|
|
||||||
**修改前**:
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
|
||||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改后**:
|
|
||||||
```python
|
|
||||||
# 已删除此导入
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 修改 B: `_create_communication_module` 方法 (L691-702)
|
|
||||||
|
|
||||||
**修改前**:
|
|
||||||
```python
|
|
||||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
|
||||||
default_config = {
|
|
||||||
**API_CONFIG,
|
|
||||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
|
||||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
|
||||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
|
||||||
}
|
|
||||||
if config:
|
|
||||||
self.bioyond_config = {**default_config, **config}
|
|
||||||
else:
|
|
||||||
self.bioyond_config = default_config
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改后**:
|
|
||||||
```python
|
|
||||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
|
||||||
"""创建Bioyond通信模块"""
|
|
||||||
# 使用传入的 config 参数(来自 bioyond_config)
|
|
||||||
# 不再依赖全局变量 API_CONFIG 等
|
|
||||||
if config:
|
|
||||||
self.bioyond_config = config
|
|
||||||
else:
|
|
||||||
# 如果没有传入配置,创建空配置(用于测试或兼容性)
|
|
||||||
self.bioyond_config = {}
|
|
||||||
|
|
||||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 修改 C: HTTP 服务配置 (L627-632)
|
|
||||||
|
|
||||||
**修改前**:
|
|
||||||
```python
|
|
||||||
self._http_service_config = {
|
|
||||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", "")),
|
|
||||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG.get("http_service_port", 0))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改后**:
|
|
||||||
```python
|
|
||||||
self._http_service_config = {
|
|
||||||
"host": bioyond_config.get("http_service_host", bioyond_config.get("HTTP_host", "")),
|
|
||||||
"port": bioyond_config.get("http_service_port", bioyond_config.get("HTTP_port", 0))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. bioyond_rpc.py
|
|
||||||
|
|
||||||
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`
|
|
||||||
|
|
||||||
#### 修改 A: 删除 config 导入 (L12)
|
|
||||||
|
|
||||||
**修改前**:
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改后**:
|
|
||||||
```python
|
|
||||||
# 已删除此导入
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 修改 B: `material_outbound` 方法 (L278-280)
|
|
||||||
|
|
||||||
**修改前**:
|
|
||||||
```python
|
|
||||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
|
||||||
"""指定库位出库物料(通过库位名称)"""
|
|
||||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改后**:
|
|
||||||
```python
|
|
||||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
|
||||||
"""指定库位出库物料(通过库位名称)"""
|
|
||||||
# location_name 参数实际上应该直接是 location_id (UUID)
|
|
||||||
location_id = location_name
|
|
||||||
```
|
|
||||||
|
|
||||||
**说明**: `LOCATION_MAPPING` 在 `config-0113.py` 中本来就是空字典 `{}`,所以直接使用 `location_name` 逻辑等价。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 关键设计决策
|
|
||||||
|
|
||||||
### 1. 嵌套 vs 扁平配置
|
|
||||||
|
|
||||||
**选择**: 嵌套结构 `config.bioyond_config`
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- ✅ 语义清晰,配置分组明确
|
|
||||||
- ✅ 参数传递直观,直接对应 `__init__` 参数
|
|
||||||
- ✅ 易于维护,不需要硬编码字段列表
|
|
||||||
- ✅ 符合 UniLab 设计模式
|
|
||||||
|
|
||||||
### 2. HTTP 服务去重
|
|
||||||
|
|
||||||
**实现**: 子类设置 `_disable_auto_http_service` 标志
|
|
||||||
|
|
||||||
```python
|
|
||||||
# bioyond_cell_workstation.py
|
|
||||||
self.bioyond_config["_disable_auto_http_service"] = True
|
|
||||||
|
|
||||||
# station.py (post_init)
|
|
||||||
if self.bioyond_config.get("_disable_auto_http_service"):
|
|
||||||
logger.info("子类已自行管理HTTP服务,跳过自动启动")
|
|
||||||
return
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 全局变量替换策略
|
|
||||||
|
|
||||||
**原则**: 所有配置从 `self.bioyond_config` 获取
|
|
||||||
|
|
||||||
**模式**:
|
|
||||||
```python
|
|
||||||
# 修改前
|
|
||||||
from config import MATERIAL_TYPE_MAPPINGS
|
|
||||||
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
|
||||||
|
|
||||||
# 修改后
|
|
||||||
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 验证结果
|
|
||||||
|
|
||||||
### 启动成功日志
|
|
||||||
```
|
|
||||||
✅ 从 JSON 配置加载 bioyond_config 成功
|
|
||||||
API Host: http://172.16.11.219:44388
|
|
||||||
HTTP Service: 172.16.11.206:8080
|
|
||||||
🔧 已设置 _disable_auto_http_service 标志,防止 HTTP 服务重复启动
|
|
||||||
✅ BioyondCellWorkstation 初始化完成
|
|
||||||
Loaded ResourceTreeSet with 1 trees, 1785 total nodes
|
|
||||||
```
|
|
||||||
|
|
||||||
### 功能验证
|
|
||||||
- ✅ 订单创建 (`create_orders_v2`)
|
|
||||||
- ✅ 质量比计算
|
|
||||||
- ✅ 物料转移 (`transfer_3_to_2_to_1`)
|
|
||||||
- ✅ HTTP 报送接收 (step_finish, sample_finish, order_finish)
|
|
||||||
- ✅ 等待机制 (`wait_for_order_finish`)
|
|
||||||
- ✅ 仓库 UUID 映射
|
|
||||||
- ✅ 物料类型映射
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 相关文档
|
|
||||||
|
|
||||||
- **配置迁移经验**: `2026-01-13_JSON配置迁移经验.md`
|
|
||||||
- **任务清单**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\task.md`
|
|
||||||
- **实施计划**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\implementation_plan.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
### 其他工作站模块
|
|
||||||
|
|
||||||
以下文件仍在使用 `config.py` 全局变量(未包含在本次修改中):
|
|
||||||
- `reaction_station.py` - 使用 `API_CONFIG`
|
|
||||||
- `experiment.py` - 使用 `API_CONFIG`, `WORKFLOW_MAPPINGS`, `MATERIAL_TYPE_MAPPINGS`
|
|
||||||
- `dispensing_station.py` - 使用 `API_CONFIG`, `WAREHOUSE_MAPPING`
|
|
||||||
- `station.py` L176, L177, L529, L530 - 动态导入 `WAREHOUSE_MAPPING`
|
|
||||||
|
|
||||||
**建议**: 后续可以统一迁移这些模块到 JSON 配置。
|
|
||||||
|
|
||||||
### config.py 文件
|
|
||||||
|
|
||||||
`config.py` 文件已恢复但**不再被 bioyond_cell 使用**。可以:
|
|
||||||
- 保留作为其他模块的参考
|
|
||||||
- 或者完全删除(如果其他模块也迁移完成)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 下一步建议
|
|
||||||
|
|
||||||
1. **清理调试代码** ✅ (已完成)
|
|
||||||
2. **提交代码到 Git**
|
|
||||||
3. **迁移其他工作站模块** (可选)
|
|
||||||
4. **更新文档和启动脚本**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**修改完成日期**: 2026-01-13
|
|
||||||
**系统状态**: ✅ 稳定运行
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,157 +0,0 @@
|
|||||||
# 批量出库 Excel 模板使用说明
|
|
||||||
|
|
||||||
**文件**: `outbound_template.xlsx`
|
|
||||||
**用途**: 配合 `auto_batch_outbound_from_xlsx()` 方法进行批量出库操作
|
|
||||||
**API 端点**: `/api/lims/storage/auto-batch-out-bound`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Excel 列说明
|
|
||||||
|
|
||||||
| 列名 | 说明 | 示例 | 必填 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `locationId` | **库位 ID(UUID)** | `3a19da43-57b5-294f-d663-154a1cc32270` | ✅ 是 |
|
|
||||||
| `warehouseId` | **仓库 ID 或名称** | `配液站内试剂仓库` | ✅ 是 |
|
|
||||||
| `quantity` | **出库数量** | `1.0`, `2.0` | ✅ 是 |
|
|
||||||
| `x` | **X 坐标(库位横向位置)** | `1`, `2`, `3` | ✅ 是 |
|
|
||||||
| `y` | **Y 坐标(库位纵向位置)** | `1`, `2`, `3` | ✅ 是 |
|
|
||||||
| `z` | **Z 坐标(库位层数/高度)** | `1`, `2`, `3` | ✅ 是 |
|
|
||||||
| `备注说明` | 可选备注信息 | `配液站内试剂仓库-A01` | ❌ 否 |
|
|
||||||
|
|
||||||
### 📐 坐标说明
|
|
||||||
|
|
||||||
**x, y, z** 是库位在仓库内的**三维坐标**:
|
|
||||||
|
|
||||||
```
|
|
||||||
仓库(例如 WH4)
|
|
||||||
├── Z=1(第1层/加样头面)
|
|
||||||
│ ├── X=1, Y=1(位置 A)
|
|
||||||
│ ├── X=2, Y=1(位置 B)
|
|
||||||
│ ├── X=3, Y=1(位置 C)
|
|
||||||
│ └── ...
|
|
||||||
│
|
|
||||||
└── Z=2(第2层/原液瓶面)
|
|
||||||
├── X=1, Y=1(位置 A)
|
|
||||||
├── X=2, Y=1(位置 B)
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- **warehouseId**: 指定哪个仓库(WH3, WH4, 配液站等)
|
|
||||||
- **x, y, z**: 在该仓库内的三维坐标
|
|
||||||
- **locationId**: 该坐标位置的唯一 UUID
|
|
||||||
|
|
||||||
### 🎯 起点与终点
|
|
||||||
|
|
||||||
**重要说明**:批量出库模板**只规定了出库的"起点"**(从哪里取物料),**没有指定"终点"**(放到哪里)。
|
|
||||||
|
|
||||||
```
|
|
||||||
出库流程:
|
|
||||||
起点(Excel 指定) → ?终点(LIMS/工作流决定)
|
|
||||||
↓
|
|
||||||
locationId, x, y, z → 由 LIMS 系统或当前工作流自动分配
|
|
||||||
```
|
|
||||||
|
|
||||||
**终点由以下方式确定:**
|
|
||||||
- **LIMS 系统自动分配**:根据当前任务自动规划目标位置
|
|
||||||
- **工作流预定义**:在创建出库任务时已绑定目标位置
|
|
||||||
- **暂存区**:默认放到出库暂存区,等待下一步操作
|
|
||||||
|
|
||||||
💡 **对比**:上料操作(`auto_feeding4to3`)则有 `targetWH` 参数可以指定目标仓库
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 如何获取 UUID?
|
|
||||||
|
|
||||||
### 方法 1:从配置文件获取
|
|
||||||
|
|
||||||
参考 `yibin_electrolyte_config.json` 中的 `warehouse_mapping`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"warehouse_mapping": {
|
|
||||||
"配液站内试剂仓库": {
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
|
|
||||||
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
|
|
||||||
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"手动堆栈": {
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
|
||||||
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方法 2:通过 API 查询
|
|
||||||
|
|
||||||
```python
|
|
||||||
material_info = hardware_interface.material_id_query(workflow_id)
|
|
||||||
locations = material_info.get("locations", [])
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 填写示例
|
|
||||||
|
|
||||||
### 示例 1:从配液站内试剂仓库出库
|
|
||||||
|
|
||||||
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
|
||||||
|------------|-------------|----------|---|---|---|----------|
|
|
||||||
| `3a19da43-57b5-294f-d663-154a1cc32270` | 配液站内试剂仓库 | 1 | 1 | 1 | 1 | A01 位置 |
|
|
||||||
| `3a19da43-57b5-7394-5f49-54efe2c9bef2` | 配液站内试剂仓库 | 2 | 2 | 1 | 1 | B01 位置 |
|
|
||||||
|
|
||||||
### 示例 2:从手动堆栈出库
|
|
||||||
|
|
||||||
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
|
||||||
|------------|-------------|----------|---|---|---|----------|
|
|
||||||
| `3a19deae-2c7a-36f5-5e41-02c5b66feaea` | 手动堆栈 | 1 | 1 | 1 | 1 | A01 |
|
|
||||||
| `3a19deae-2c7a-dc6d-c41e-ef285d946cfe` | 手动堆栈 | 1 | 1 | 2 | 1 | A02 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💻 使用方法
|
|
||||||
|
|
||||||
```python
|
|
||||||
from bioyond_cell_workstation import BioyondCellWorkstation
|
|
||||||
|
|
||||||
# 初始化工作站
|
|
||||||
workstation = BioyondCellWorkstation(config=config, deck=deck)
|
|
||||||
|
|
||||||
# 调用批量出库方法
|
|
||||||
result = workstation.auto_batch_outbound_from_xlsx(
|
|
||||||
xlsx_path="outbound_template.xlsx"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
1. **locationId 必须是有效的 UUID**,不能使用库位名称
|
|
||||||
2. **x, y, z 坐标必须与 locationId 对应**,表示该库位在仓库内的位置
|
|
||||||
3. **quantity 必须是数字**,可以是整数或浮点数
|
|
||||||
4. Excel 文件必须包含表头行
|
|
||||||
5. 空行会被自动跳过
|
|
||||||
6. 确保 UUID 与实际库位对应,否则 API 会报错
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 相关文件
|
|
||||||
|
|
||||||
- **配置文件**: `yibin_electrolyte_config.json`
|
|
||||||
- **Python 代码**: `bioyond_cell_workstation.py` (L630-695)
|
|
||||||
- **生成脚本**: `create_outbound_template.py`
|
|
||||||
- **上料模板**: `material_template.xlsx`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 重新生成模板
|
|
||||||
|
|
||||||
```bash
|
|
||||||
conda activate newunilab
|
|
||||||
python create_outbound_template.py
|
|
||||||
```
|
|
||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
from unilabos.device_comms.rpc import BaseRequest
|
from unilabos.device_comms.rpc import BaseRequest
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
import json
|
import json
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||||
|
|
||||||
|
|
||||||
class SimpleLogger:
|
class SimpleLogger:
|
||||||
@@ -49,14 +49,6 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.api_key = config["api_key"]
|
self.api_key = config["api_key"]
|
||||||
self.host = config["api_host"]
|
self.host = config["api_host"]
|
||||||
|
|
||||||
# 初始化 location_mapping
|
|
||||||
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
|
|
||||||
self.location_mapping = {}
|
|
||||||
warehouse_mapping = self.config.get("warehouse_mapping", {})
|
|
||||||
for warehouse_name, warehouse_config in warehouse_mapping.items():
|
|
||||||
if "site_uuids" in warehouse_config:
|
|
||||||
self.location_mapping.update(warehouse_config["site_uuids"])
|
|
||||||
self._logger = SimpleLogger()
|
self._logger = SimpleLogger()
|
||||||
self.material_cache = {}
|
self.material_cache = {}
|
||||||
self._load_material_cache()
|
self._load_material_cache()
|
||||||
@@ -184,40 +176,7 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
print(f"add material data: {response['data']}")
|
print(f"add material data: {response['data']}")
|
||||||
|
return response.get("data", {})
|
||||||
# 自动更新缓存
|
|
||||||
data = response.get("data", {})
|
|
||||||
if data:
|
|
||||||
if isinstance(data, str):
|
|
||||||
# 如果返回的是字符串,通常是ID
|
|
||||||
mat_id = data
|
|
||||||
name = params.get("name")
|
|
||||||
else:
|
|
||||||
# 如果返回的是字典,尝试获取name和id
|
|
||||||
name = data.get("name") or params.get("name")
|
|
||||||
mat_id = data.get("id")
|
|
||||||
|
|
||||||
if name and mat_id:
|
|
||||||
self.material_cache[name] = mat_id
|
|
||||||
print(f"已自动更新缓存: {name} -> {mat_id}")
|
|
||||||
|
|
||||||
# 处理返回数据中的 details (如果有)
|
|
||||||
# 有些 API 返回结构可能直接包含 details,或者在 data 字段中
|
|
||||||
details = data.get("details", []) if isinstance(data, dict) else []
|
|
||||||
if not details and isinstance(data, dict):
|
|
||||||
details = data.get("detail", [])
|
|
||||||
|
|
||||||
if details:
|
|
||||||
for detail in details:
|
|
||||||
d_name = detail.get("name")
|
|
||||||
# 尝试从不同字段获取 ID
|
|
||||||
d_id = detail.get("id") or detail.get("detailMaterialId")
|
|
||||||
|
|
||||||
if d_name and d_id:
|
|
||||||
self.material_cache[d_name] = d_id
|
|
||||||
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def query_matial_type_id(self, data) -> list:
|
def query_matial_type_id(self, data) -> list:
|
||||||
"""查找物料typeid"""
|
"""查找物料typeid"""
|
||||||
@@ -244,7 +203,7 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
params={
|
params={
|
||||||
"apiKey": self.api_key,
|
"apiKey": self.api_key,
|
||||||
"requestTime": self.get_current_time_iso8601(),
|
"requestTime": self.get_current_time_iso8601(),
|
||||||
"data": 0,
|
"data": {},
|
||||||
})
|
})
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return []
|
return []
|
||||||
@@ -314,19 +273,11 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
|
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# 自动更新缓存 - 移除被删除的物料
|
|
||||||
for name, mid in list(self.material_cache.items()):
|
|
||||||
if mid == material_id:
|
|
||||||
del self.material_cache[name]
|
|
||||||
print(f"已从缓存移除物料: {name}")
|
|
||||||
break
|
|
||||||
|
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
"""指定库位出库物料(通过库位名称)"""
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
location_id = self.location_mapping.get(location_name, location_name)
|
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"materialId": material_id,
|
"materialId": material_id,
|
||||||
@@ -1152,10 +1103,6 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
for detail_material in detail_materials:
|
for detail_material in detail_materials:
|
||||||
detail_name = detail_material.get("name")
|
detail_name = detail_material.get("name")
|
||||||
detail_id = detail_material.get("detailMaterialId")
|
detail_id = detail_material.get("detailMaterialId")
|
||||||
if not detail_id:
|
|
||||||
# 尝试其他可能的字段
|
|
||||||
detail_id = detail_material.get("id")
|
|
||||||
|
|
||||||
if detail_name and detail_id:
|
if detail_name and detail_id:
|
||||||
self.material_cache[detail_name] = detail_id
|
self.material_cache[detail_name] = detail_id
|
||||||
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
||||||
@@ -1176,14 +1123,6 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||||
return material_id
|
return material_id
|
||||||
|
|
||||||
# 如果缓存中没有,尝试刷新缓存
|
|
||||||
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
|
||||||
self.refresh_material_cache()
|
|
||||||
if material_name_or_id in self.material_cache:
|
|
||||||
material_id = self.material_cache[material_name_or_id]
|
|
||||||
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
|
||||||
return material_id
|
|
||||||
|
|
||||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||||
return material_name_or_id
|
return material_name_or_id
|
||||||
|
|
||||||
|
|||||||
142
unilabos/devices/workstation/bioyond_studio/config.py
Normal file
142
unilabos/devices/workstation/bioyond_studio/config.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# config.py
|
||||||
|
"""
|
||||||
|
配置文件 - 包含所有配置信息和映射关系
|
||||||
|
"""
|
||||||
|
|
||||||
|
# API配置
|
||||||
|
API_CONFIG = {
|
||||||
|
"api_key": "",
|
||||||
|
"api_host": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流映射配置
|
||||||
|
WORKFLOW_MAPPINGS = {
|
||||||
|
"reactor_taken_out": "",
|
||||||
|
"reactor_taken_in": "",
|
||||||
|
"Solid_feeding_vials": "",
|
||||||
|
"Liquid_feeding_vials(non-titration)": "",
|
||||||
|
"Liquid_feeding_solvents": "",
|
||||||
|
"Liquid_feeding(titration)": "",
|
||||||
|
"liquid_feeding_beaker": "",
|
||||||
|
"Drip_back": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流名称到DisplaySectionName的映射
|
||||||
|
WORKFLOW_TO_SECTION_MAP = {
|
||||||
|
'reactor_taken_in': '反应器放入',
|
||||||
|
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||||
|
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||||
|
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||||
|
'Solid_feeding_vials': '固体投料-小瓶',
|
||||||
|
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||||
|
'reactor_taken_out': '反应器取出'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 库位映射配置
|
||||||
|
WAREHOUSE_MAPPING = {
|
||||||
|
"粉末堆栈": {
|
||||||
|
"uuid": "",
|
||||||
|
"site_uuids": {
|
||||||
|
# 样品板
|
||||||
|
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||||
|
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||||
|
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||||
|
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||||
|
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||||
|
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||||
|
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||||
|
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||||
|
# 分装板
|
||||||
|
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||||
|
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||||
|
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||||
|
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||||
|
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||||
|
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||||
|
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||||
|
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"溶液堆栈": {
|
||||||
|
"uuid": "",
|
||||||
|
"site_uuids": {
|
||||||
|
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||||
|
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||||
|
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||||
|
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||||
|
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||||
|
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||||
|
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||||
|
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||||
|
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||||
|
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||||
|
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||||
|
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||||
|
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||||
|
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||||
|
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||||
|
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"试剂堆栈": {
|
||||||
|
"uuid": "",
|
||||||
|
"site_uuids": {
|
||||||
|
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
||||||
|
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
||||||
|
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
||||||
|
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
||||||
|
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
||||||
|
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
||||||
|
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
||||||
|
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 物料类型配置
|
||||||
|
MATERIAL_TYPE_MAPPINGS = {
|
||||||
|
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||||
|
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
||||||
|
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||||
|
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||||
|
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||||
|
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||||
|
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 步骤参数配置(各工作流的步骤UUID)
|
||||||
|
WORKFLOW_STEP_IDS = {
|
||||||
|
"reactor_taken_in": {
|
||||||
|
"config": ""
|
||||||
|
},
|
||||||
|
"liquid_feeding_beaker": {
|
||||||
|
"liquid": "",
|
||||||
|
"observe": ""
|
||||||
|
},
|
||||||
|
"liquid_feeding_vials_non_titration": {
|
||||||
|
"liquid": "",
|
||||||
|
"observe": ""
|
||||||
|
},
|
||||||
|
"liquid_feeding_solvents": {
|
||||||
|
"liquid": "",
|
||||||
|
"observe": ""
|
||||||
|
},
|
||||||
|
"solid_feeding_vials": {
|
||||||
|
"feeding": "",
|
||||||
|
"observe": ""
|
||||||
|
},
|
||||||
|
"liquid_feeding_titration": {
|
||||||
|
"liquid": "",
|
||||||
|
"observe": ""
|
||||||
|
},
|
||||||
|
"drip_back": {
|
||||||
|
"liquid": "",
|
||||||
|
"observe": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOCATION_MAPPING = {}
|
||||||
|
|
||||||
|
ACTION_NAMES = {}
|
||||||
|
|
||||||
|
HTTP_SERVICE_CONFIG = {}
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
# config.py
|
|
||||||
"""
|
|
||||||
Bioyond工作站配置文件
|
|
||||||
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 基础配置
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# API配置
|
|
||||||
API_CONFIG = {
|
|
||||||
"api_key": "DE9BDDA0",
|
|
||||||
"api_host": "http://192.168.1.200:44402"
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTP 报送服务配置
|
|
||||||
HTTP_SERVICE_CONFIG = {
|
|
||||||
"http_service_host": "127.0.0.1", # 监听地址
|
|
||||||
"http_service_port": 8080, # 监听端口
|
|
||||||
}
|
|
||||||
|
|
||||||
# Deck配置 - 反应站工作台配置
|
|
||||||
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 工作流配置
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# 工作流ID映射
|
|
||||||
WORKFLOW_MAPPINGS = {
|
|
||||||
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
|
|
||||||
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
|
|
||||||
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
|
|
||||||
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
|
|
||||||
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
|
|
||||||
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
|
|
||||||
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
|
||||||
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流名称到显示名称的映射
|
|
||||||
WORKFLOW_TO_SECTION_MAP = {
|
|
||||||
'reactor_taken_in': '反应器放入',
|
|
||||||
'reactor_taken_out': '反应器取出',
|
|
||||||
'Solid_feeding_vials': '固体投料-小瓶',
|
|
||||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
|
||||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
|
||||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
|
||||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
|
||||||
'Drip_back': '液体回滴'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流步骤ID配置
|
|
||||||
WORKFLOW_STEP_IDS = {
|
|
||||||
"reactor_taken_in": {
|
|
||||||
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
|
|
||||||
},
|
|
||||||
"liquid_feeding_beaker": {
|
|
||||||
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
|
|
||||||
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
|
|
||||||
},
|
|
||||||
"liquid_feeding_vials_non_titration": {
|
|
||||||
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
|
|
||||||
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
|
|
||||||
},
|
|
||||||
"liquid_feeding_solvents": {
|
|
||||||
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
|
|
||||||
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
|
|
||||||
},
|
|
||||||
"solid_feeding_vials": {
|
|
||||||
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
|
|
||||||
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
|
|
||||||
},
|
|
||||||
"liquid_feeding_titration": {
|
|
||||||
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
|
|
||||||
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
|
|
||||||
},
|
|
||||||
"drip_back": {
|
|
||||||
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
|
|
||||||
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流动作名称配置
|
|
||||||
ACTION_NAMES = {
|
|
||||||
"reactor_taken_in": {
|
|
||||||
"config": "通量-配置",
|
|
||||||
"stirring": "反应模块-开始搅拌"
|
|
||||||
},
|
|
||||||
"solid_feeding_vials": {
|
|
||||||
"feeding": "粉末加样模块-投料",
|
|
||||||
"observe": "反应模块-观察搅拌结果"
|
|
||||||
},
|
|
||||||
"liquid_feeding_vials_non_titration": {
|
|
||||||
"liquid": "稀释液瓶加液位-液体投料",
|
|
||||||
"observe": "反应模块-滴定结果观察"
|
|
||||||
},
|
|
||||||
"liquid_feeding_solvents": {
|
|
||||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
|
||||||
"observe": "反应模块-观察搅拌结果"
|
|
||||||
},
|
|
||||||
"liquid_feeding_titration": {
|
|
||||||
"liquid": "稀释液瓶加液位-稀释液吸液分液",
|
|
||||||
"observe": "反应模块-滴定结果观察"
|
|
||||||
},
|
|
||||||
"liquid_feeding_beaker": {
|
|
||||||
"liquid": "烧杯溶液放置位-烧杯吸液分液",
|
|
||||||
"observe": "反应模块-观察搅拌结果"
|
|
||||||
},
|
|
||||||
"drip_back": {
|
|
||||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
|
||||||
"observe": "反应模块-向下滴定结果观察"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 仓库配置
|
|
||||||
# ============================================================================
|
|
||||||
# 说明:
|
|
||||||
# - 出库和入库操作都需要UUID
|
|
||||||
WAREHOUSE_MAPPING = {
|
|
||||||
# ========== 反应站仓库 ==========
|
|
||||||
|
|
||||||
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01~D04)
|
|
||||||
"堆栈1左": {
|
|
||||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
|
||||||
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
|
||||||
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
|
|
||||||
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
|
|
||||||
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
|
||||||
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
|
|
||||||
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
|
|
||||||
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
|
|
||||||
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
|
|
||||||
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
|
|
||||||
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
|
|
||||||
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
|
|
||||||
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
|
|
||||||
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
|
|
||||||
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
|
|
||||||
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05~D08)
|
|
||||||
"堆栈1右": {
|
|
||||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
|
||||||
"site_uuids": {
|
|
||||||
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
|
|
||||||
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
|
|
||||||
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
|
|
||||||
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
|
|
||||||
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
|
|
||||||
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
|
|
||||||
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
|
|
||||||
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
|
|
||||||
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
|
|
||||||
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
|
|
||||||
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
|
|
||||||
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
|
|
||||||
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
|
|
||||||
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
|
|
||||||
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
|
|
||||||
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
# 站内试剂存放堆栈
|
|
||||||
"站内试剂存放堆栈": {
|
|
||||||
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
|
||||||
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
# 测量小瓶仓库(测密度)
|
|
||||||
"测量小瓶仓库": {
|
|
||||||
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
|
|
||||||
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
|
|
||||||
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
|
|
||||||
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
|
|
||||||
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
|
|
||||||
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
|
|
||||||
"站内Tip盒堆栈": {
|
|
||||||
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
|
|
||||||
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
|
|
||||||
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
|
|
||||||
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
|
|
||||||
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
|
|
||||||
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
# ========== 配液站仓库 ==========
|
|
||||||
"粉末堆栈": {
|
|
||||||
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
|
||||||
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
|
|
||||||
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
|
||||||
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
|
||||||
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
|
||||||
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
|
||||||
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
|
||||||
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
|
||||||
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
|
||||||
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
|
||||||
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
|
||||||
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
|
||||||
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
|
||||||
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
|
||||||
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
|
||||||
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"溶液堆栈": {
|
|
||||||
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
|
||||||
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
|
||||||
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
|
||||||
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
|
|
||||||
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
|
||||||
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
|
||||||
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
|
||||||
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
|
||||||
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
|
||||||
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
|
||||||
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
|
||||||
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
|
||||||
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
|
||||||
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
|
|
||||||
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
|
|
||||||
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"试剂堆栈": {
|
|
||||||
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
|
|
||||||
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
|
|
||||||
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
|
|
||||||
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
|
|
||||||
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
|
|
||||||
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
|
|
||||||
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
|
|
||||||
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 物料类型配置
|
|
||||||
# ============================================================================
|
|
||||||
# 说明:
|
|
||||||
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
|
|
||||||
# - 这个映射基于 resource.model 属性 (不是显示名称!)
|
|
||||||
# - UUID为空表示该类型暂未在Bioyond系统中定义
|
|
||||||
MATERIAL_TYPE_MAPPINGS = {
|
|
||||||
# ================================================配液站资源============================================================
|
|
||||||
# ==================================================样品===============================================================
|
|
||||||
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
|
|
||||||
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
|
|
||||||
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
|
|
||||||
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
|
|
||||||
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
|
|
||||||
# ==================================================试剂===============================================================
|
|
||||||
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板(8孔)
|
|
||||||
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 动态生成的库位UUID映射(从WAREHOUSE_MAPPING中提取)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
LOCATION_MAPPING = {}
|
|
||||||
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
|
|
||||||
if "site_uuids" in warehouse_config:
|
|
||||||
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 物料默认参数配置
|
|
||||||
# ============================================================================
|
|
||||||
# 说明:
|
|
||||||
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
|
|
||||||
# - 格式: 物料名称 → {参数字典}
|
|
||||||
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
|
|
||||||
# - unit: 物料的计量单位(会用于 unit 字段)
|
|
||||||
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
|
|
||||||
|
|
||||||
MATERIAL_DEFAULT_PARAMETERS = {
|
|
||||||
# 溶剂类
|
|
||||||
"NMP": {
|
|
||||||
"unit": "毫升",
|
|
||||||
"density": "1.03",
|
|
||||||
"densityUnit": "g/mL",
|
|
||||||
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
|
|
||||||
},
|
|
||||||
# 可以继续添加其他物料...
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 物料类型默认参数配置
|
|
||||||
# ============================================================================
|
|
||||||
# 说明:
|
|
||||||
# - 为特定物料类型(UUID)自动添加默认参数
|
|
||||||
# - 格式: Bioyond类型UUID → {参数字典}
|
|
||||||
# - 优先级低于按名称匹配的配置
|
|
||||||
MATERIAL_TYPE_PARAMETERS = {
|
|
||||||
# 示例:
|
|
||||||
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
|
|
||||||
# "unit": "个"
|
|
||||||
# }
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,7 @@ import time
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
import requests
|
import requests
|
||||||
import pint
|
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
||||||
|
|
||||||
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||||
@@ -26,89 +25,13 @@ class ComputeExperimentDesignReturn(TypedDict):
|
|||||||
class BioyondDispensingStation(BioyondWorkstation):
|
class BioyondDispensingStation(BioyondWorkstation):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: dict = None,
|
config,
|
||||||
deck=None,
|
# 桌子
|
||||||
protocol_type=None,
|
deck,
|
||||||
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""初始化配液站
|
super().__init__(config, deck, *args, **kwargs)
|
||||||
|
|
||||||
Args:
|
|
||||||
config: 配置字典,应包含material_type_mappings等配置
|
|
||||||
deck: Deck对象
|
|
||||||
protocol_type: 协议类型(由ROS系统传递,此处忽略)
|
|
||||||
**kwargs: 其他可能的参数
|
|
||||||
"""
|
|
||||||
if config is None:
|
|
||||||
config = {}
|
|
||||||
|
|
||||||
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
|
|
||||||
config.update(kwargs)
|
|
||||||
|
|
||||||
if deck is None and config:
|
|
||||||
deck = config.get('deck')
|
|
||||||
|
|
||||||
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
|
|
||||||
# 从配置中读取 warehouse_mapping,并应用到实际的 deck 资源上
|
|
||||||
if config and "warehouse_mapping" in config and deck:
|
|
||||||
warehouse_mapping = config["warehouse_mapping"]
|
|
||||||
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
|
|
||||||
|
|
||||||
user_deck = deck
|
|
||||||
# 初始化 warehouses 字典
|
|
||||||
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
|
|
||||||
user_deck.warehouses = {}
|
|
||||||
|
|
||||||
# 1. 尝试从 children 中查找匹配的资源
|
|
||||||
for child in user_deck.children:
|
|
||||||
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
|
|
||||||
if child.name in warehouse_mapping:
|
|
||||||
user_deck.warehouses[child.name] = child
|
|
||||||
print(f" - 从子资源中找到 warehouse: {child.name}")
|
|
||||||
|
|
||||||
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
|
|
||||||
if not user_deck.warehouses and hasattr(user_deck, "setup"):
|
|
||||||
print(" - 尝试调用 deck.setup() 初始化仓库...")
|
|
||||||
try:
|
|
||||||
user_deck.setup()
|
|
||||||
# setup 后重新检查
|
|
||||||
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
|
|
||||||
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" - 调用 setup() 失败: {e}")
|
|
||||||
|
|
||||||
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
|
|
||||||
if not user_deck.warehouses:
|
|
||||||
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
|
|
||||||
|
|
||||||
for wh_name, wh_config in warehouse_mapping.items():
|
|
||||||
target_uuid = wh_config.get("uuid")
|
|
||||||
|
|
||||||
# 尝试在 deck.warehouses 中查找
|
|
||||||
wh_resource = None
|
|
||||||
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
|
|
||||||
wh_resource = user_deck.warehouses[wh_name]
|
|
||||||
|
|
||||||
# 如果没找到,尝试在所有子资源中查找
|
|
||||||
if not wh_resource:
|
|
||||||
wh_resource = user_deck.get_resource(wh_name)
|
|
||||||
|
|
||||||
if wh_resource:
|
|
||||||
if target_uuid:
|
|
||||||
current_uuid = getattr(wh_resource, "uuid", None)
|
|
||||||
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
|
|
||||||
|
|
||||||
# 动态添加 uuid 属性
|
|
||||||
wh_resource.uuid = target_uuid
|
|
||||||
# 同时也确保 category 正确,避免 graphio 识别错误
|
|
||||||
# wh_resource.category = "warehouse"
|
|
||||||
else:
|
|
||||||
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
|
|
||||||
else:
|
|
||||||
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
|
|
||||||
|
|
||||||
super().__init__(bioyond_config=config, deck=deck)
|
|
||||||
|
|
||||||
# self.config = config
|
# self.config = config
|
||||||
# self.api_key = config["api_key"]
|
# self.api_key = config["api_key"]
|
||||||
# self.host = config["api_host"]
|
# self.host = config["api_host"]
|
||||||
@@ -120,41 +43,6 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||||
self.order_completion_status = {}
|
self.order_completion_status = {}
|
||||||
|
|
||||||
# 初始化 pint 单位注册表
|
|
||||||
self.ureg = pint.UnitRegistry()
|
|
||||||
|
|
||||||
# 化合物信息
|
|
||||||
self.compound_info = {
|
|
||||||
"MolWt": {
|
|
||||||
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
|
||||||
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
|
||||||
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
|
||||||
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
|
||||||
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
|
||||||
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
|
||||||
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
|
||||||
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
|
||||||
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
|
||||||
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
|
||||||
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
|
||||||
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
|
||||||
},
|
|
||||||
"FuncGroup": {
|
|
||||||
"MDA": "Amine",
|
|
||||||
"TDA": "Amine",
|
|
||||||
"PAPP": "Amine",
|
|
||||||
"BTDA": "Anhydride",
|
|
||||||
"BPDA": "Anhydride",
|
|
||||||
"6FAP": "Amine",
|
|
||||||
"MPDA": "Amine",
|
|
||||||
"SIDA": "Amine",
|
|
||||||
"PMDA": "Anhydride",
|
|
||||||
"ODA": "Amine",
|
|
||||||
"4,4'-ODA": "Amine",
|
|
||||||
"134": "Amine",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||||
"""项目接口通用POST调用
|
"""项目接口通用POST调用
|
||||||
|
|
||||||
@@ -166,7 +54,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": self.bioyond_config["api_key"],
|
"apiKey": API_CONFIG["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -197,7 +85,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": self.bioyond_config["api_key"],
|
"apiKey": API_CONFIG["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -230,22 +118,20 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
ratio = json.loads(ratio)
|
ratio = json.loads(ratio)
|
||||||
except Exception:
|
except Exception:
|
||||||
ratio = {}
|
ratio = {}
|
||||||
|
root = str(Path(__file__).resolve().parents[3])
|
||||||
|
if root not in sys.path:
|
||||||
|
sys.path.append(root)
|
||||||
|
try:
|
||||||
|
mod = importlib.import_module("tem.compute")
|
||||||
|
except Exception as e:
|
||||||
|
raise BioyondException(f"无法导入计算模块: {e}")
|
||||||
try:
|
try:
|
||||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BioyondException(f"参数解析失败: {e}")
|
raise BioyondException(f"参数解析失败: {e}")
|
||||||
|
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
||||||
# 2. 调用内部计算方法
|
|
||||||
res = self._generate_experiment_design(
|
|
||||||
ratio=ratio,
|
|
||||||
wt_percent=wp,
|
|
||||||
m_tot=mt,
|
|
||||||
titration_percent=tp
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. 构造返回结果
|
|
||||||
out = {
|
out = {
|
||||||
"solutions": res.get("solutions", []),
|
"solutions": res.get("solutions", []),
|
||||||
"titration": res.get("titration", {}),
|
"titration": res.get("titration", {}),
|
||||||
@@ -254,248 +140,11 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
"return_info": json.dumps(res, ensure_ascii=False)
|
"return_info": json.dumps(res, ensure_ascii=False)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
except BioyondException:
|
except BioyondException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BioyondException(str(e))
|
raise BioyondException(str(e))
|
||||||
|
|
||||||
def _generate_experiment_design(
|
|
||||||
self,
|
|
||||||
ratio: dict,
|
|
||||||
wt_percent: float = 0.25,
|
|
||||||
m_tot: float = 70,
|
|
||||||
titration_percent: float = 0.03,
|
|
||||||
) -> dict:
|
|
||||||
"""内部方法:生成实验设计
|
|
||||||
|
|
||||||
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
|
||||||
wt_percent: 固体重量百分比
|
|
||||||
m_tot: 反应混合物总质量(g)
|
|
||||||
titration_percent: 滴定溶液百分比
|
|
||||||
|
|
||||||
返回:
|
|
||||||
包含实验设计详细参数的字典
|
|
||||||
"""
|
|
||||||
# 溶剂密度
|
|
||||||
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
|
||||||
# 二酐溶解度
|
|
||||||
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
|
||||||
# 投入固体时最小溶剂体积
|
|
||||||
V_min = 30 * self.ureg.ml
|
|
||||||
m_tot = m_tot * self.ureg.g
|
|
||||||
|
|
||||||
# 保持ratio中的顺序
|
|
||||||
compound_names = list(ratio.keys())
|
|
||||||
compound_ratios = list(ratio.values())
|
|
||||||
|
|
||||||
# 验证所有化合物是否在 compound_info 中定义
|
|
||||||
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
|
||||||
if undefined_compounds:
|
|
||||||
available = list(self.compound_info["MolWt"].keys())
|
|
||||||
raise ValueError(
|
|
||||||
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
|
||||||
f"可用的化合物: {available}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取各化合物的分子量和官能团类型
|
|
||||||
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
|
||||||
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
|
||||||
|
|
||||||
# 记录化合物信息用于调试
|
|
||||||
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
|
||||||
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
|
||||||
|
|
||||||
# 按原始顺序分离二胺和二酐
|
|
||||||
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
|
||||||
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
|
||||||
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
|
||||||
|
|
||||||
if not diamine_compounds or not anhydride_compounds:
|
|
||||||
raise ValueError(
|
|
||||||
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
|
||||||
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
|
||||||
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 计算加权平均分子量 (基于摩尔比)
|
|
||||||
total_molar_ratio = sum(compound_ratios)
|
|
||||||
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
|
||||||
|
|
||||||
# 取最后一个二酐用于滴定
|
|
||||||
titration_anhydride = anhydride_compounds[-1]
|
|
||||||
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
|
||||||
|
|
||||||
# 二胺溶液配制参数 - 每种二胺单独配制
|
|
||||||
diamine_solutions = []
|
|
||||||
total_diamine_volume = 0 * self.ureg.ml
|
|
||||||
|
|
||||||
# 计算反应物的总摩尔量
|
|
||||||
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
|
||||||
|
|
||||||
for name, ratio_val, mw, order_index in diamine_compounds:
|
|
||||||
# 跳过 SIDA
|
|
||||||
if name == "SIDA":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 计算该二胺需要的摩尔数
|
|
||||||
n_diamine_needed = n_reactant * ratio_val
|
|
||||||
|
|
||||||
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
|
||||||
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
|
||||||
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
|
||||||
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
|
||||||
|
|
||||||
# 计算该二胺溶液的浓度
|
|
||||||
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
|
||||||
|
|
||||||
# 计算需要移取的溶液体积
|
|
||||||
V_diamine_needed = n_diamine_needed / c_diamine
|
|
||||||
|
|
||||||
diamine_solutions.append({
|
|
||||||
"name": name,
|
|
||||||
"order": order_index,
|
|
||||||
"solid_mass": m_diamine_solid.magnitude,
|
|
||||||
"solvent_volume": V_solvent_for_this.magnitude,
|
|
||||||
"concentration": c_diamine.magnitude,
|
|
||||||
"volume_needed": V_diamine_needed.magnitude,
|
|
||||||
"molar_ratio": ratio_val
|
|
||||||
})
|
|
||||||
|
|
||||||
total_diamine_volume += V_diamine_needed
|
|
||||||
|
|
||||||
# 按原始顺序排序
|
|
||||||
diamine_solutions.sort(key=lambda x: x["order"])
|
|
||||||
|
|
||||||
# 计算滴定二酐的质量
|
|
||||||
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
|
||||||
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
|
||||||
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
|
||||||
m_titration_10 = m_titration_anhydride * titration_percent
|
|
||||||
|
|
||||||
# 计算其他固体二酐的质量 (按顺序)
|
|
||||||
solid_anhydride_masses = []
|
|
||||||
for name, ratio_val, mw, order_index in solid_anhydrides:
|
|
||||||
mass = n_reactant * ratio_val * mw
|
|
||||||
solid_anhydride_masses.append({
|
|
||||||
"name": name,
|
|
||||||
"order": order_index,
|
|
||||||
"mass": mass.magnitude,
|
|
||||||
"molar_ratio": ratio_val
|
|
||||||
})
|
|
||||||
|
|
||||||
# 按原始顺序排序
|
|
||||||
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
|
||||||
|
|
||||||
# 计算溶剂用量
|
|
||||||
total_diamine_solution_mass = sum(
|
|
||||||
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
|
||||||
) * self.ureg.ml
|
|
||||||
|
|
||||||
# 预估滴定溶剂量、计算补加溶剂量
|
|
||||||
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
|
||||||
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
|
||||||
|
|
||||||
# 检查最小溶剂体积要求
|
|
||||||
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
|
||||||
m_tot_min = V_min / total_liquid_volume * m_tot
|
|
||||||
|
|
||||||
# 如果需要,按比例放大
|
|
||||||
scale_factor = 1.0
|
|
||||||
if m_tot_min > m_tot:
|
|
||||||
scale_factor = (m_tot_min / m_tot).magnitude
|
|
||||||
m_titration_90 *= scale_factor
|
|
||||||
m_titration_10 *= scale_factor
|
|
||||||
m_solvent_add *= scale_factor
|
|
||||||
m_solvent_titration *= scale_factor
|
|
||||||
|
|
||||||
# 更新二胺溶液用量
|
|
||||||
for sol in diamine_solutions:
|
|
||||||
sol["volume_needed"] *= scale_factor
|
|
||||||
|
|
||||||
# 更新固体二酐用量
|
|
||||||
for anhydride in solid_anhydride_masses:
|
|
||||||
anhydride["mass"] *= scale_factor
|
|
||||||
|
|
||||||
m_tot = m_tot_min
|
|
||||||
|
|
||||||
# 生成投料顺序
|
|
||||||
feeding_order = []
|
|
||||||
|
|
||||||
# 1. 固体二酐 (按顺序)
|
|
||||||
for anhydride in solid_anhydride_masses:
|
|
||||||
feeding_order.append({
|
|
||||||
"step": len(feeding_order) + 1,
|
|
||||||
"type": "solid_anhydride",
|
|
||||||
"name": anhydride["name"],
|
|
||||||
"amount": anhydride["mass"],
|
|
||||||
"order": anhydride["order"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# 2. 二胺溶液 (按顺序)
|
|
||||||
for sol in diamine_solutions:
|
|
||||||
feeding_order.append({
|
|
||||||
"step": len(feeding_order) + 1,
|
|
||||||
"type": "diamine_solution",
|
|
||||||
"name": sol["name"],
|
|
||||||
"amount": sol["volume_needed"],
|
|
||||||
"order": sol["order"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# 3. 主要二酐粉末
|
|
||||||
feeding_order.append({
|
|
||||||
"step": len(feeding_order) + 1,
|
|
||||||
"type": "main_anhydride",
|
|
||||||
"name": titration_name,
|
|
||||||
"amount": m_titration_90.magnitude,
|
|
||||||
"order": titration_anhydride[3]
|
|
||||||
})
|
|
||||||
|
|
||||||
# 4. 补加溶剂
|
|
||||||
if m_solvent_add > 0:
|
|
||||||
feeding_order.append({
|
|
||||||
"step": len(feeding_order) + 1,
|
|
||||||
"type": "additional_solvent",
|
|
||||||
"name": "溶剂",
|
|
||||||
"amount": m_solvent_add.magnitude,
|
|
||||||
"order": 999
|
|
||||||
})
|
|
||||||
|
|
||||||
# 5. 滴定二酐溶液
|
|
||||||
feeding_order.append({
|
|
||||||
"step": len(feeding_order) + 1,
|
|
||||||
"type": "titration_anhydride",
|
|
||||||
"name": f"{titration_name} 滴定液",
|
|
||||||
"amount": m_titration_10.magnitude,
|
|
||||||
"titration_solvent": m_solvent_titration.magnitude,
|
|
||||||
"order": titration_anhydride[3]
|
|
||||||
})
|
|
||||||
|
|
||||||
# 返回实验设计结果
|
|
||||||
results = {
|
|
||||||
"total_mass": m_tot.magnitude,
|
|
||||||
"scale_factor": scale_factor,
|
|
||||||
"solutions": diamine_solutions,
|
|
||||||
"solids": solid_anhydride_masses,
|
|
||||||
"titration": {
|
|
||||||
"name": titration_name,
|
|
||||||
"main_portion": m_titration_90.magnitude,
|
|
||||||
"titration_portion": m_titration_10.magnitude,
|
|
||||||
"titration_solvent": m_solvent_titration.magnitude,
|
|
||||||
},
|
|
||||||
"solvents": {
|
|
||||||
"additional_solvent": m_solvent_add.magnitude,
|
|
||||||
"total_liquid_volume": total_liquid_volume.magnitude
|
|
||||||
},
|
|
||||||
"feeding_order": feeding_order,
|
|
||||||
"minimum_required_mass": m_tot_min.magnitude
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
# 90%10%小瓶投料任务创建方法
|
# 90%10%小瓶投料任务创建方法
|
||||||
def create_90_10_vial_feeding_task(self,
|
def create_90_10_vial_feeding_task(self,
|
||||||
order_name: str = None,
|
order_name: str = None,
|
||||||
@@ -1312,108 +961,6 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
'actualVolume': actual_volume
|
'actualVolume': actual_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
def _simplify_report(self, report) -> Dict[str, Any]:
|
|
||||||
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
|
||||||
if not isinstance(report, dict):
|
|
||||||
return report
|
|
||||||
|
|
||||||
data = report.get('data', {})
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return report
|
|
||||||
|
|
||||||
# 提取关键信息
|
|
||||||
simplified = {
|
|
||||||
'name': data.get('name'),
|
|
||||||
'code': data.get('code'),
|
|
||||||
'requester': data.get('requester'),
|
|
||||||
'workflowName': data.get('workflowName'),
|
|
||||||
'workflowStep': data.get('workflowStep'),
|
|
||||||
'requestTime': data.get('requestTime'),
|
|
||||||
'startPreparationTime': data.get('startPreparationTime'),
|
|
||||||
'completeTime': data.get('completeTime'),
|
|
||||||
'useTime': data.get('useTime'),
|
|
||||||
'status': data.get('status'),
|
|
||||||
'statusName': data.get('statusName'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 提取物料信息(简化版)
|
|
||||||
pre_intakes = data.get('preIntakes', [])
|
|
||||||
if pre_intakes and isinstance(pre_intakes, list):
|
|
||||||
first_intake = pre_intakes[0]
|
|
||||||
sample_materials = first_intake.get('sampleMaterials', [])
|
|
||||||
|
|
||||||
# 简化物料信息
|
|
||||||
simplified_materials = []
|
|
||||||
for material in sample_materials:
|
|
||||||
if isinstance(material, dict):
|
|
||||||
mat_info = {
|
|
||||||
'materialName': material.get('materialName'),
|
|
||||||
'materialTypeName': material.get('materialTypeName'),
|
|
||||||
'materialCode': material.get('materialCode'),
|
|
||||||
'materialLocation': material.get('materialLocation'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 解析parameters中的关键信息(如密度、加料历史等)
|
|
||||||
params_str = material.get('parameters', '{}')
|
|
||||||
try:
|
|
||||||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
|
||||||
if isinstance(params, dict):
|
|
||||||
# 只保留关键参数
|
|
||||||
if 'density' in params:
|
|
||||||
mat_info['density'] = params['density']
|
|
||||||
if 'feedingHistory' in params:
|
|
||||||
mat_info['feedingHistory'] = params['feedingHistory']
|
|
||||||
if 'liquidVolume' in params:
|
|
||||||
mat_info['liquidVolume'] = params['liquidVolume']
|
|
||||||
if 'm_diamine_tot' in params:
|
|
||||||
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
|
||||||
if 'wt_diamine' in params:
|
|
||||||
mat_info['wt_diamine'] = params['wt_diamine']
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
simplified_materials.append(mat_info)
|
|
||||||
|
|
||||||
simplified['sampleMaterials'] = simplified_materials
|
|
||||||
|
|
||||||
# 提取extraProperties中的实际值
|
|
||||||
extra_props = first_intake.get('extraProperties', {})
|
|
||||||
if isinstance(extra_props, dict):
|
|
||||||
simplified_extra = {}
|
|
||||||
for key, value in extra_props.items():
|
|
||||||
try:
|
|
||||||
parsed_value = json.loads(value) if isinstance(value, str) else value
|
|
||||||
simplified_extra[key] = parsed_value
|
|
||||||
except:
|
|
||||||
simplified_extra[key] = value
|
|
||||||
simplified['extraProperties'] = simplified_extra
|
|
||||||
|
|
||||||
return {
|
|
||||||
'data': simplified,
|
|
||||||
'code': report.get('code'),
|
|
||||||
'message': report.get('message'),
|
|
||||||
'timestamp': report.get('timestamp')
|
|
||||||
}
|
|
||||||
|
|
||||||
def scheduler_start(self) -> dict:
|
|
||||||
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 包含return_info的字典,return_info为整型(1=成功)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
BioyondException: 调度器启动失败时抛出异常
|
|
||||||
"""
|
|
||||||
result = self.hardware_interface.scheduler_start()
|
|
||||||
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
|
||||||
|
|
||||||
if result != 1:
|
|
||||||
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
|
||||||
self.hardware_interface._logger.error(error_msg)
|
|
||||||
raise BioyondException(error_msg)
|
|
||||||
|
|
||||||
return {"return_info": result}
|
|
||||||
|
|
||||||
# 等待多个任务完成并获取实验报告
|
# 等待多个任务完成并获取实验报告
|
||||||
def wait_for_multiple_orders_and_get_reports(self,
|
def wait_for_multiple_orders_and_get_reports(self,
|
||||||
batch_create_result: str = None,
|
batch_create_result: str = None,
|
||||||
@@ -1455,12 +1002,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证batch_create_result参数
|
# 验证batch_create_result参数
|
||||||
if not batch_create_result or batch_create_result == "":
|
if not batch_create_result or batch_create_result == "":
|
||||||
raise BioyondException(
|
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
||||||
"batch_create_result参数为空,请确保:\n"
|
|
||||||
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
|
||||||
"2. batch_create节点成功执行并返回了结果\n"
|
|
||||||
"3. 检查上游batch_create任务是否成功创建了订单"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 解析batch_create_result JSON对象
|
# 解析batch_create_result JSON对象
|
||||||
try:
|
try:
|
||||||
@@ -1489,17 +1031,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证提取的数据
|
# 验证提取的数据
|
||||||
if not order_codes:
|
if not order_codes:
|
||||||
self.hardware_interface._logger.error(
|
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
||||||
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
|
||||||
)
|
|
||||||
raise BioyondException(
|
|
||||||
"batch_create_result中未找到order_codes或为空。\n"
|
|
||||||
"可能的原因:\n"
|
|
||||||
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
|
||||||
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
|
||||||
"3. Bioyond系统状态异常\n"
|
|
||||||
f"请检查batch_create任务的执行结果"
|
|
||||||
)
|
|
||||||
if not order_ids:
|
if not order_ids:
|
||||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||||
|
|
||||||
@@ -1582,8 +1114,6 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
self.hardware_interface._logger.info(
|
self.hardware_interface._logger.info(
|
||||||
f"成功获取任务 {order_code} 的实验报告"
|
f"成功获取任务 {order_code} 的实验报告"
|
||||||
)
|
)
|
||||||
# 简化报告,去除冗余信息
|
|
||||||
report = self._simplify_report(report)
|
|
||||||
|
|
||||||
reports.append({
|
reports.append({
|
||||||
"order_code": order_code,
|
"order_code": order_code,
|
||||||
@@ -1758,7 +1288,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
from .config import WAREHOUSE_MAPPING
|
||||||
results = []
|
results = []
|
||||||
successful_count = 0
|
successful_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ Bioyond Workstation Implementation
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
import json
|
import json
|
||||||
@@ -24,94 +23,12 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
|||||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
||||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||||
|
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||||
|
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||||
|
)
|
||||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||||
|
|
||||||
|
|
||||||
class ConnectionMonitor:
|
|
||||||
"""Bioyond连接监控器"""
|
|
||||||
def __init__(self, workstation, check_interval=30):
|
|
||||||
self.workstation = workstation
|
|
||||||
self.check_interval = check_interval
|
|
||||||
self._running = False
|
|
||||||
self._thread = None
|
|
||||||
self._last_status = "unknown"
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
self._running = True
|
|
||||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
|
||||||
self._thread.start()
|
|
||||||
logger.info("Bioyond连接监控器已启动")
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
self._running = False
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=2)
|
|
||||||
logger.info("Bioyond连接监控器已停止")
|
|
||||||
|
|
||||||
def _monitor_loop(self):
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
# 使用 lightweight API 检查连接
|
|
||||||
# query_matial_type_list 是比较快的查询
|
|
||||||
start_time = time.time()
|
|
||||||
result = self.workstation.hardware_interface.material_type_list()
|
|
||||||
|
|
||||||
status = "online" if result else "offline"
|
|
||||||
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
|
||||||
|
|
||||||
if status != self._last_status:
|
|
||||||
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
|
||||||
self._publish_event(status, msg)
|
|
||||||
self._last_status = status
|
|
||||||
|
|
||||||
# 发布心跳 (可选,或者只在状态变更时发布)
|
|
||||||
# self._publish_event(status, msg)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Bioyond连接检查异常: {e}")
|
|
||||||
if self._last_status != "error":
|
|
||||||
self._publish_event("error", str(e))
|
|
||||||
self._last_status = "error"
|
|
||||||
|
|
||||||
time.sleep(self.check_interval)
|
|
||||||
|
|
||||||
def _publish_event(self, status, message):
|
|
||||||
try:
|
|
||||||
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
|
||||||
event_data = {
|
|
||||||
"status": status,
|
|
||||||
"message": message,
|
|
||||||
"timestamp": datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
|
||||||
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
|
||||||
# 话题: /<namespace>/events/device_status
|
|
||||||
ns = self.workstation._ros_node.namespace
|
|
||||||
topic = f"{ns}/events/device_status"
|
|
||||||
|
|
||||||
# 使用 ROS2DeviceNode 的发布功能
|
|
||||||
# 如果没有预定义的 publisher,需要动态创建
|
|
||||||
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
|
||||||
# 这里我们先尝试用 String json 发布
|
|
||||||
|
|
||||||
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
|
||||||
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
|
||||||
if not hasattr(self.workstation, "_device_status_pub"):
|
|
||||||
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
|
||||||
String, topic, 10
|
|
||||||
)
|
|
||||||
|
|
||||||
self.workstation._device_status_pub.publish(
|
|
||||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"发布设备状态事件失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||||
"""Bioyond资源同步器
|
"""Bioyond资源同步器
|
||||||
|
|
||||||
@@ -257,8 +174,9 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||||
|
|
||||||
# 第1步:从配置中获取仓库配置
|
# 第1步:获取仓库配置
|
||||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
from .config import WAREHOUSE_MAPPING
|
||||||
|
warehouse_mapping = WAREHOUSE_MAPPING
|
||||||
|
|
||||||
# 确定目标仓库名称
|
# 确定目标仓库名称
|
||||||
parent_name = None
|
parent_name = None
|
||||||
@@ -320,20 +238,14 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
# 第2步:转换为 Bioyond 格式
|
# 第2步:转换为 Bioyond 格式
|
||||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||||
|
|
||||||
# 从配置中获取物料默认参数
|
# 导入物料默认参数配置
|
||||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
|
||||||
|
|
||||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
|
||||||
merged_params = material_default_params.copy()
|
|
||||||
for type_id, params in material_type_params.items():
|
|
||||||
merged_params[f"type:{type_id}"] = params
|
|
||||||
|
|
||||||
bioyond_material = resource_plr_to_bioyond(
|
bioyond_material = resource_plr_to_bioyond(
|
||||||
[resource],
|
[resource],
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||||
material_params=merged_params
|
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||||
@@ -556,20 +468,13 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
return material_bioyond_id
|
return material_bioyond_id
|
||||||
|
|
||||||
# 转换为 Bioyond 格式
|
# 转换为 Bioyond 格式
|
||||||
# 从配置中获取物料默认参数
|
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
|
||||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
|
||||||
|
|
||||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
|
||||||
merged_params = material_default_params.copy()
|
|
||||||
for type_id, params in material_type_params.items():
|
|
||||||
merged_params[f"type:{type_id}"] = params
|
|
||||||
|
|
||||||
bioyond_material = resource_plr_to_bioyond(
|
bioyond_material = resource_plr_to_bioyond(
|
||||||
[resource],
|
[resource],
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||||
material_params=merged_params
|
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||||
@@ -623,7 +528,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||||
|
|
||||||
# 获取仓库配置和目标库位 UUID
|
# 获取仓库配置和目标库位 UUID
|
||||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
from .config import WAREHOUSE_MAPPING
|
||||||
|
warehouse_mapping = WAREHOUSE_MAPPING
|
||||||
|
|
||||||
parent_name = None
|
parent_name = None
|
||||||
target_location_uuid = None
|
target_location_uuid = None
|
||||||
@@ -678,44 +584,6 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
集成Bioyond物料管理的工作站实现
|
集成Bioyond物料管理的工作站实现
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _publish_task_status(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
task_type: str,
|
|
||||||
status: str,
|
|
||||||
result: dict = None,
|
|
||||||
progress: float = 0.0,
|
|
||||||
task_code: str = None
|
|
||||||
):
|
|
||||||
"""发布任务状态事件"""
|
|
||||||
try:
|
|
||||||
if not getattr(self, "_ros_node", None):
|
|
||||||
return
|
|
||||||
|
|
||||||
event_data = {
|
|
||||||
"task_id": task_id,
|
|
||||||
"task_code": task_code,
|
|
||||||
"task_type": task_type,
|
|
||||||
"status": status,
|
|
||||||
"progress": progress,
|
|
||||||
"timestamp": datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
if result:
|
|
||||||
event_data["result"] = result
|
|
||||||
|
|
||||||
topic = f"{self._ros_node.namespace}/events/task_status"
|
|
||||||
|
|
||||||
if not hasattr(self, "_task_status_pub"):
|
|
||||||
self._task_status_pub = self._ros_node.create_publisher(
|
|
||||||
String, topic, 10
|
|
||||||
)
|
|
||||||
|
|
||||||
self._task_status_pub.publish(
|
|
||||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"发布任务状态事件失败: {e}")
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||||
@@ -737,28 +605,10 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||||
|
|
||||||
# 初始化 warehouses 属性
|
# 初始化 warehouses 属性
|
||||||
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
self.deck.warehouses = {}
|
||||||
self.deck.warehouses = {}
|
for resource in self.deck.children:
|
||||||
|
if isinstance(resource, WareHouse):
|
||||||
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
self.deck.warehouses[resource.name] = resource
|
||||||
if not self.deck.warehouses:
|
|
||||||
for resource in self.deck.children:
|
|
||||||
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
|
||||||
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
|
|
||||||
|
|
||||||
# 如果配置中有定义,也可以认定为 warehouse
|
|
||||||
if not is_warehouse and "warehouse_mapping" in bioyond_config:
|
|
||||||
if resource.name in bioyond_config["warehouse_mapping"]:
|
|
||||||
is_warehouse = True
|
|
||||||
|
|
||||||
if is_warehouse:
|
|
||||||
self.deck.warehouses[resource.name] = resource
|
|
||||||
# 确保 category 被正确设置,方便后续使用
|
|
||||||
if getattr(resource, "category", "") != "warehouse":
|
|
||||||
try:
|
|
||||||
resource.category = "warehouse"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 创建通信模块
|
# 创建通信模块
|
||||||
self._create_communication_module(bioyond_config)
|
self._create_communication_module(bioyond_config)
|
||||||
@@ -777,22 +627,18 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
||||||
|
|
||||||
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
||||||
# 从 bioyond_config 中的 http_service_config 获取
|
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
|
||||||
http_service_cfg = bioyond_config.get("http_service_config", {})
|
|
||||||
self._http_service_config = {
|
self._http_service_config = {
|
||||||
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
||||||
"port": http_service_cfg.get("http_service_port", 8080)
|
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
||||||
}
|
}
|
||||||
self.http_service = None # 将在 post_init 启动
|
self.http_service = None # 将在 post_init 中启动
|
||||||
self.connection_monitor = None # 将在 post_init 启动
|
|
||||||
|
|
||||||
logger.info(f"Bioyond工作站初始化完成")
|
logger.info(f"Bioyond工作站初始化完成")
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||||
try:
|
try:
|
||||||
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
|
||||||
self.connection_monitor.stop()
|
|
||||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||||
logger.info("正在停止 HTTP 报送服务...")
|
logger.info("正在停止 HTTP 报送服务...")
|
||||||
self.http_service.stop()
|
self.http_service.stop()
|
||||||
@@ -802,19 +648,8 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||||
self._ros_node = ros_node
|
self._ros_node = ros_node
|
||||||
|
|
||||||
# 启动连接监控
|
|
||||||
try:
|
|
||||||
self.connection_monitor = ConnectionMonitor(self)
|
|
||||||
self.connection_monitor.start()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"启动连接监控失败: {e}")
|
|
||||||
|
|
||||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||||
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
|
if hasattr(self, '_http_service_config'):
|
||||||
if self.bioyond_config.get("_disable_auto_http_service"):
|
|
||||||
logger.info("🔧 检测到 _disable_auto_http_service 标志,跳过自动启动 HTTP 服务")
|
|
||||||
logger.info(" 子类(BioyondCellWorkstation)已自行管理 HTTP 服务")
|
|
||||||
elif hasattr(self, '_http_service_config'):
|
|
||||||
try:
|
try:
|
||||||
self.http_service = WorkstationHTTPService(
|
self.http_service = WorkstationHTTPService(
|
||||||
workstation_instance=self,
|
workstation_instance=self,
|
||||||
@@ -853,14 +688,19 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
"""创建Bioyond通信模块"""
|
"""创建Bioyond通信模块"""
|
||||||
# 直接使用传入的配置,不再使用默认值
|
# 创建默认配置
|
||||||
# 所有配置必须从 JSON 文件中提供
|
default_config = {
|
||||||
|
**API_CONFIG,
|
||||||
|
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||||
|
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||||
|
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果传入了 config,合并配置(config 中的值会覆盖默认值)
|
||||||
if config:
|
if config:
|
||||||
self.bioyond_config = config
|
self.bioyond_config = {**default_config, **config}
|
||||||
else:
|
else:
|
||||||
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
|
self.bioyond_config = default_config
|
||||||
self.bioyond_config = {}
|
|
||||||
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
|
||||||
|
|
||||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||||
|
|
||||||
@@ -1174,15 +1014,7 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
workflow_id = self._get_workflow(actual_workflow_name)
|
workflow_id = self._get_workflow(actual_workflow_name)
|
||||||
if workflow_id:
|
if workflow_id:
|
||||||
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
self.workflow_sequence.append(workflow_id)
|
||||||
if isinstance(self.workflow_sequence, list):
|
|
||||||
self.workflow_sequence.append(workflow_id)
|
|
||||||
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
|
||||||
self._cached_workflow_sequence.append(workflow_id)
|
|
||||||
else:
|
|
||||||
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -1383,22 +1215,6 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||||
# 例如:更新数据库、触发后续流程等
|
# 例如:更新数据库、触发后续流程等
|
||||||
|
|
||||||
# 发布任务状态事件 (running/progress update)
|
|
||||||
self._publish_task_status(
|
|
||||||
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
|
||||||
task_code=data.get('orderCode'),
|
|
||||||
task_type="bioyond_step",
|
|
||||||
status="running",
|
|
||||||
progress=0.5, # 步骤完成视为任务进行中
|
|
||||||
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新物料信息
|
|
||||||
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
|
||||||
logger.info(f"[步骤完成报送] 触发物料同步...")
|
|
||||||
self.resource_synchronizer.sync_from_external()
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"step_id": data.get('stepId'),
|
"step_id": data.get('stepId'),
|
||||||
@@ -1433,17 +1249,6 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||||
|
|
||||||
# 发布任务状态事件
|
|
||||||
self._publish_task_status(
|
|
||||||
task_id=data.get('orderCode'),
|
|
||||||
task_code=data.get('orderCode'),
|
|
||||||
task_type="bioyond_sample",
|
|
||||||
status="running",
|
|
||||||
progress=0.7,
|
|
||||||
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"sample_id": data.get('sampleId'),
|
"sample_id": data.get('sampleId'),
|
||||||
@@ -1483,32 +1288,6 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||||
# 例如:更新物料库存、生成报表等
|
# 例如:更新物料库存、生成报表等
|
||||||
|
|
||||||
# 映射状态到事件状态
|
|
||||||
event_status = "completed"
|
|
||||||
if str(data.get('status')) in ["-11", "-12"]:
|
|
||||||
event_status = "error"
|
|
||||||
elif str(data.get('status')) == "30":
|
|
||||||
event_status = "completed"
|
|
||||||
else:
|
|
||||||
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
|
||||||
|
|
||||||
# 发布任务状态事件
|
|
||||||
self._publish_task_status(
|
|
||||||
task_id=data.get('orderCode'),
|
|
||||||
task_code=data.get('orderCode'),
|
|
||||||
task_type="bioyond_order",
|
|
||||||
status=event_status,
|
|
||||||
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
|
||||||
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新物料信息
|
|
||||||
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
|
||||||
if event_status == "completed":
|
|
||||||
logger.info(f"[任务完成报送] 触发物料同步...")
|
|
||||||
self.resource_synchronizer.sync_from_external()
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"order_code": data.get('orderCode'),
|
"order_code": data.get('orderCode'),
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
# Modbus CSV 地址映射说明
|
|
||||||
|
|
||||||
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
|
|
||||||
|
|
||||||
## 1. CSV 文件结构
|
|
||||||
|
|
||||||
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
|
|
||||||
|
|
||||||
每一行定义了一个 Modbus 节点,包含以下关键列:
|
|
||||||
|
|
||||||
| 列名 | 说明 | 示例 |
|
|
||||||
|------|------|------|
|
|
||||||
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
|
|
||||||
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
|
|
||||||
| **Comment** | 注释说明 | `使用铝箔垫` |
|
|
||||||
| **Attribute** | 属性 (通常留空或用于额外标记) | |
|
|
||||||
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
|
|
||||||
| **Address** | **Modbus 地址** (十进制) | `8340` |
|
|
||||||
|
|
||||||
### 示例行 (铝箔垫片)
|
|
||||||
|
|
||||||
```csv
|
|
||||||
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
|
|
||||||
```
|
|
||||||
|
|
||||||
- **名称**: `COIL_ALUMINUM_FOIL`
|
|
||||||
- **类型**: `coil` (线圈,读写单个位)
|
|
||||||
- **地址**: `8340`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 加载与注册流程
|
|
||||||
|
|
||||||
在 `coin_cell_assembly.py` 的初始化代码中:
|
|
||||||
|
|
||||||
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
|
|
||||||
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 代码位置: coin_cell_assembly.py (L174-175)
|
|
||||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
|
|
||||||
self.client = modbus_client.register_node_list(self.nodes)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 代码中的使用方式
|
|
||||||
|
|
||||||
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
|
|
||||||
|
|
||||||
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 代码位置: qiming_coin_cell_code 函数 (L1048)
|
|
||||||
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
|
|
||||||
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
|
|
||||||
|
|
||||||
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 地址转换注意事项 (Modbus vs PLC)
|
|
||||||
|
|
||||||
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**。
|
|
||||||
|
|
||||||
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...` 或 `%MW...`)。这两者之间通常需要转换。
|
|
||||||
|
|
||||||
### 常见的转换规则 (示例)
|
|
||||||
|
|
||||||
- **Coil (线圈) %QX**:
|
|
||||||
- `Modbus地址 = 字节地址 * 8 + 位偏移`
|
|
||||||
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
|
|
||||||
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
|
|
||||||
|
|
||||||
- **Register (寄存器) %MW**:
|
|
||||||
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
|
|
||||||
|
|
||||||
### 验证方法
|
|
||||||
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`。
|
|
||||||
|
|
||||||
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
# 2026-01-13 物料搜寻确认弹窗自动处理功能
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本次更新为设备初始化流程添加了**物料搜寻确认弹窗自动检测与处理功能**。在设备初始化过程中,PLC 会弹出物料搜寻确认对话框,现在系统可以根据用户参数自动点击"是"或"否"按钮,无需手动干预。
|
|
||||||
|
|
||||||
## 背景问题
|
|
||||||
|
|
||||||
### 原有流程
|
|
||||||
1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备
|
|
||||||
2. PLC 在初始化过程中弹出物料搜寻确认对话框
|
|
||||||
3. **需要人工手动点击**"是"或"否"按钮
|
|
||||||
4. PLC 继续完成初始化并启动
|
|
||||||
|
|
||||||
### 存在的问题
|
|
||||||
- 需要人工干预,无法实现全自动化
|
|
||||||
- 影响批量生产效率
|
|
||||||
- 容易遗忘点击导致流程卡住
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
### 新增 Modbus 地址配置
|
|
||||||
|
|
||||||
在 `coin_cell_assembly_b.csv` 第 69-71 行添加三个 coil:
|
|
||||||
|
|
||||||
| Name | DeviceType | Address | 说明 |
|
|
||||||
|------|-----------|---------|------|
|
|
||||||
| COIL_MATERIAL_SEARCH_DIALOG_APPEAR | coil | 6470 | 物料搜寻确认弹窗画面是否出现 |
|
|
||||||
| COIL_MATERIAL_SEARCH_CONFIRM_YES | coil | 6480 | 初始化物料搜寻确认按钮"是" |
|
|
||||||
| COIL_MATERIAL_SEARCH_CONFIRM_NO | coil | 6490 | 初始化物料搜寻确认按钮"否" |
|
|
||||||
|
|
||||||
**Modbus 地址转换:**
|
|
||||||
- CSV 6470 → Modbus 5176 (弹窗出现)
|
|
||||||
- CSV 6480 → Modbus 5184 (按钮"是")
|
|
||||||
- CSV 6490 → Modbus 5192 (按钮"否")
|
|
||||||
|
|
||||||
## 代码修改详情
|
|
||||||
|
|
||||||
### 1. coin_cell_assembly.py
|
|
||||||
|
|
||||||
#### 1.1 新增辅助方法 `_handle_material_search_dialog()`
|
|
||||||
|
|
||||||
**位置:** 第 799-901 行
|
|
||||||
|
|
||||||
**功能:**
|
|
||||||
- 监测物料搜寻确认弹窗是否出现(Coil 5176)
|
|
||||||
- 根据 `enable_search` 参数自动点击对应按钮
|
|
||||||
- 使用**脉冲模式**模拟真实按钮操作:`True` → 保持 0.5 秒 → `False`
|
|
||||||
|
|
||||||
**参数:**
|
|
||||||
- `enable_search: bool` - True=点击"是"(启用物料搜寻), False=点击"否"(不启用)
|
|
||||||
- `timeout: int = 30` - 等待弹窗出现的最大时间(秒)
|
|
||||||
|
|
||||||
**逻辑流程:**
|
|
||||||
```python
|
|
||||||
1. 监测 COIL_MATERIAL_SEARCH_DIALOG_APPEAR (每 0.5 秒检查一次)
|
|
||||||
2. 检测到弹窗出现 (Coil = True)
|
|
||||||
3. 选择按钮:
|
|
||||||
- enable_search=True → COIL_MATERIAL_SEARCH_CONFIRM_YES
|
|
||||||
- enable_search=False → COIL_MATERIAL_SEARCH_CONFIRM_NO
|
|
||||||
4. 执行脉冲操作:
|
|
||||||
- 写入 True (按下按钮)
|
|
||||||
- 等待 0.5 秒
|
|
||||||
- 写入 False (释放按钮)
|
|
||||||
- 验证状态
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 修改 `func_pack_device_init_auto_start_combined()`
|
|
||||||
|
|
||||||
**位置:** 第 904-1115 行
|
|
||||||
|
|
||||||
**主要改动:**
|
|
||||||
|
|
||||||
1. **添加新参数**
|
|
||||||
```python
|
|
||||||
def func_pack_device_init_auto_start_combined(
|
|
||||||
self,
|
|
||||||
material_search_enable: bool = False # 新增参数
|
|
||||||
) -> bool:
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **内联初始化逻辑并集成弹窗检测**
|
|
||||||
- 不再调用 `self.func_pack_device_init()`
|
|
||||||
- 将初始化逻辑直接实现在函数内
|
|
||||||
- **在等待初始化完成的循环中实时检测弹窗**
|
|
||||||
- 避免死锁:PLC 等待弹窗确认 ↔ 代码等待初始化完成
|
|
||||||
|
|
||||||
3. **关键代码片段**
|
|
||||||
```python
|
|
||||||
# 等待初始化完成,同时检测物料搜寻弹窗
|
|
||||||
while (self._sys_init_status()) == False:
|
|
||||||
# 检查超时
|
|
||||||
if time.time() - start_wait > max_wait_time:
|
|
||||||
raise RuntimeError(f"初始化超时")
|
|
||||||
|
|
||||||
# 如果还没处理弹窗,检测弹窗是否出现
|
|
||||||
if not dialog_handled:
|
|
||||||
dialog_state = self.client.use_node('COIL_MATERIAL_SEARCH_DIALOG_APPEAR').read(1)
|
|
||||||
if dialog_actual: # 弹窗出现
|
|
||||||
# 执行脉冲按钮点击
|
|
||||||
button_node.write(True) # 按下
|
|
||||||
time.sleep(0.5) # 保持
|
|
||||||
button_node.write(False) # 释放
|
|
||||||
dialog_handled = True
|
|
||||||
|
|
||||||
time.sleep(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **步骤调整**
|
|
||||||
- 步骤 0: 前置条件检查
|
|
||||||
- 步骤 1: 设备初始化(**包含弹窗检测**)
|
|
||||||
- 步骤 1.5: 已在步骤 1 中完成
|
|
||||||
- 步骤 2: 切换自动模式
|
|
||||||
- 步骤 3: 启动设备
|
|
||||||
|
|
||||||
### 2. coin_cell_workstation.yaml
|
|
||||||
|
|
||||||
**位置:** 第 292-312 行
|
|
||||||
|
|
||||||
**修改内容:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto-func_pack_device_init_auto_start_combined:
|
|
||||||
goal_default:
|
|
||||||
material_search_enable: false # 新增默认值
|
|
||||||
|
|
||||||
schema:
|
|
||||||
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
|
||||||
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
material_search_enable: # 新增参数配置
|
|
||||||
default: false
|
|
||||||
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
|
||||||
type: boolean
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 测试脚本(已创建,用户已删除)
|
|
||||||
|
|
||||||
#### 3.1 test_material_search_dialog.py
|
|
||||||
- 从 CSV 动态加载 Modbus 地址
|
|
||||||
- 支持 4 种测试模式:
|
|
||||||
- `query` - 查询所有状态
|
|
||||||
- `dialog <0|1>` - 设置弹窗出现/消失
|
|
||||||
- `yes` - 脉冲点击"是"按钮
|
|
||||||
- `no` - 脉冲点击"否"按钮
|
|
||||||
- 兼容 pymodbus 3.x API
|
|
||||||
|
|
||||||
#### 3.2 更新其他测试脚本
|
|
||||||
- `test_coin_cell_reset.py` - 更新为 pymodbus 3.x API
|
|
||||||
- `test_unilab_interact.py` - 更新为 pymodbus 3.x API
|
|
||||||
|
|
||||||
## 使用方法
|
|
||||||
|
|
||||||
### 参数说明
|
|
||||||
|
|
||||||
| 参数 | 类型 | 默认值 | 说明 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `material_search_enable` | boolean | `false` | 是否启用物料搜寻功能 |
|
|
||||||
|
|
||||||
### 调用示例
|
|
||||||
|
|
||||||
#### 1. 不启用物料搜寻(默认)
|
|
||||||
```python
|
|
||||||
# 默认参数,点击"否"按钮
|
|
||||||
await device.func_pack_device_init_auto_start_combined()
|
|
||||||
```
|
|
||||||
|
|
||||||
或在 YAML workflow 中:
|
|
||||||
```yaml
|
|
||||||
# 使用默认值 false,不启用物料搜寻
|
|
||||||
- BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 启用物料搜寻
|
|
||||||
```python
|
|
||||||
# 显式设置为 True,点击"是"按钮
|
|
||||||
await device.func_pack_device_init_auto_start_combined(
|
|
||||||
material_search_enable=True
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
或在 YAML workflow 中:
|
|
||||||
```yaml
|
|
||||||
- BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
|
||||||
goal:
|
|
||||||
material_search_enable: true # 启用物料搜寻
|
|
||||||
```
|
|
||||||
|
|
||||||
## 执行日志示例
|
|
||||||
|
|
||||||
```
|
|
||||||
26-01-13 [21:32:44] [INFO] 开始组合操作:设备初始化 → 物料搜寻确认 → 自动模式 → 启动
|
|
||||||
26-01-13 [21:32:44] [INFO] 【步骤 0/4】前置条件检查...
|
|
||||||
26-01-13 [21:32:44] [INFO] ✓ REG_UNILAB_INTERACT 检查通过
|
|
||||||
26-01-13 [21:32:44] [INFO] ✓ COIL_GB_L_IGNORE_CMD 检查通过
|
|
||||||
26-01-13 [21:32:44] [INFO] 【步骤 1/4】设备初始化...
|
|
||||||
26-01-13 [21:32:44] [INFO] 切换手动模式...
|
|
||||||
26-01-13 [21:32:46] [INFO] 发送初始化命令...
|
|
||||||
26-01-13 [21:32:47] [INFO] 等待初始化完成(同时监测物料搜寻弹窗)...
|
|
||||||
26-01-13 [21:33:05] [INFO] ✓ 在初始化过程中检测到物料搜寻确认弹窗!
|
|
||||||
26-01-13 [21:33:05] [INFO] 用户选择: 不启用物料搜寻(点击否)
|
|
||||||
26-01-13 [21:33:05] [INFO] → 按下按钮 '否'
|
|
||||||
26-01-13 [21:33:06] [INFO] → 释放按钮 '否'
|
|
||||||
26-01-13 [21:33:07] [INFO] ✓ 成功处理物料搜寻确认弹窗(选择: 否)
|
|
||||||
26-01-13 [21:33:08] [INFO] ✓ 初始化状态完成
|
|
||||||
26-01-13 [21:33:12] [INFO] ✓ 设备初始化完成
|
|
||||||
26-01-13 [21:33:12] [INFO] 【步骤 1.5/4】物料搜寻确认已在初始化过程中完成
|
|
||||||
26-01-13 [21:33:12] [INFO] 【步骤 2/4】切换自动模式...
|
|
||||||
26-01-13 [21:33:15] [INFO] ✓ 切换自动模式完成
|
|
||||||
26-01-13 [21:33:15] [INFO] 【步骤 3/4】启动设备...
|
|
||||||
26-01-13 [21:33:18] [INFO] ✓ 启动设备完成
|
|
||||||
26-01-13 [21:33:18] [INFO] 组合操作完成:设备已成功初始化、确认物料搜寻、切换自动模式并启动
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技术要点
|
|
||||||
|
|
||||||
### 1. 脉冲模式按钮操作
|
|
||||||
模拟真实按钮按压过程:
|
|
||||||
1. 写入 `True` (按下)
|
|
||||||
2. 保持 0.5 秒
|
|
||||||
3. 写入 `False` (释放)
|
|
||||||
4. 验证状态
|
|
||||||
|
|
||||||
### 2. 避免死锁
|
|
||||||
**问题:** PLC 在初始化过程中等待弹窗确认,而代码等待初始化完成
|
|
||||||
**解决:** 在初始化等待循环中实时检测弹窗,一旦出现立即处理
|
|
||||||
|
|
||||||
### 3. 超时保护
|
|
||||||
- 弹窗检测超时:30 秒(在 `_handle_material_search_dialog` 中)
|
|
||||||
- 初始化超时:120 秒(在 `func_pack_device_init_auto_start_combined` 中)
|
|
||||||
|
|
||||||
### 4. PyModbus 3.x API 兼容
|
|
||||||
所有 Modbus 操作使用 keyword arguments:
|
|
||||||
```python
|
|
||||||
# 读取
|
|
||||||
client.read_coils(address=5176, count=1)
|
|
||||||
|
|
||||||
# 写入
|
|
||||||
client.write_coil(address=5184, value=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 向后兼容性
|
|
||||||
|
|
||||||
### 保留的原有函数
|
|
||||||
- `func_pack_device_init()` - 单独的初始化函数,不包含弹窗处理
|
|
||||||
- 仍可在 YAML 中通过 `auto-func_pack_device_init` 调用
|
|
||||||
- 用于不需要自动处理弹窗的场景
|
|
||||||
|
|
||||||
### 新增的功能
|
|
||||||
- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理
|
|
||||||
- 通过参数控制,默认行为与之前兼容(点击"否")
|
|
||||||
|
|
||||||
## 验证测试
|
|
||||||
|
|
||||||
### 测试场景
|
|
||||||
|
|
||||||
#### 场景 1:默认参数(不启用物料搜寻)
|
|
||||||
```bash
|
|
||||||
# 调用时不传参数
|
|
||||||
BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
|
||||||
```
|
|
||||||
**预期结果:**
|
|
||||||
- ✅ 检测到弹窗
|
|
||||||
- ✅ 自动点击"否"按钮
|
|
||||||
- ✅ 初始化完成并启动成功
|
|
||||||
|
|
||||||
#### 场景 2:启用物料搜寻
|
|
||||||
```bash
|
|
||||||
# 设置 material_search_enable=true
|
|
||||||
BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
|
||||||
goal:
|
|
||||||
material_search_enable: true
|
|
||||||
```
|
|
||||||
**预期结果:**
|
|
||||||
- ✅ 检测到弹窗
|
|
||||||
- ✅ 自动点击"是"按钮
|
|
||||||
- ✅ 初始化完成并启动成功
|
|
||||||
|
|
||||||
### 实际测试结果
|
|
||||||
|
|
||||||
**测试时间:** 2026-01-13 21:32:43
|
|
||||||
**测试参数:** `material_search_enable: false`
|
|
||||||
**测试结果:** ✅ 成功
|
|
||||||
|
|
||||||
**关键时间节点:**
|
|
||||||
- 21:33:05 - 检测到弹窗
|
|
||||||
- 21:33:05 - 按下"否"按钮
|
|
||||||
- 21:33:06 - 释放"否"按钮
|
|
||||||
- 21:33:07 - 弹窗处理完成
|
|
||||||
- 21:33:08 - 初始化状态完成
|
|
||||||
- 21:33:18 - 整个流程完成
|
|
||||||
|
|
||||||
**总耗时:** 约 35 秒(包含初始化全过程)
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **CSV 配置依赖**
|
|
||||||
- 确保 `coin_cell_assembly_b.csv` 包含 69-71 行的 coil 配置
|
|
||||||
- 地址转换逻辑:`modbus_addr = (csv_addr // 10) * 8 + (csv_addr % 10)`
|
|
||||||
|
|
||||||
2. **默认行为**
|
|
||||||
- 默认 `material_search_enable=false`,即不启用物料搜寻
|
|
||||||
- 如需启用,必须显式设置为 `true`
|
|
||||||
|
|
||||||
3. **日志级别**
|
|
||||||
- 弹窗检测过程中的 `waiting for init_cmd` 使用 DEBUG 级别
|
|
||||||
- 关键操作(检测到弹窗、按钮操作)使用 INFO 级别
|
|
||||||
|
|
||||||
4. **原有函数保留**
|
|
||||||
- `func_pack_device_init()` 仍然可用,但不包含弹窗处理
|
|
||||||
- 如果单独调用此函数,仍需手动处理弹窗
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
### 修改的文件
|
|
||||||
1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`
|
|
||||||
- 新增 `_handle_material_search_dialog()` 方法
|
|
||||||
- 修改 `func_pack_device_init_auto_start_combined()` 函数
|
|
||||||
|
|
||||||
2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml`
|
|
||||||
- 更新 `auto-func_pack_device_init_auto_start_combined` 配置
|
|
||||||
- 添加 `material_search_enable` 参数说明
|
|
||||||
|
|
||||||
3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv`
|
|
||||||
- 第 69-71 行添加三个 coil 配置
|
|
||||||
|
|
||||||
### 创建的测试文件(已删除)
|
|
||||||
1. `test_material_search_dialog.py` - 物料搜寻弹窗测试脚本
|
|
||||||
2. `test_coin_cell_reset.py` - 复位功能测试(更新为 pymodbus 3.x)
|
|
||||||
3. `test_unilab_interact.py` - Unilab 交互测试(更新为 pymodbus 3.x)
|
|
||||||
|
|
||||||
## 总结
|
|
||||||
|
|
||||||
本次更新成功实现了设备初始化过程中物料搜寻确认弹窗的自动化处理,主要优势:
|
|
||||||
|
|
||||||
✅ **全自动化** - 无需人工干预
|
|
||||||
✅ **参数可配** - 灵活控制是否启用物料搜寻
|
|
||||||
✅ **实时检测** - 在初始化等待循环中检测,避免死锁
|
|
||||||
✅ **脉冲模式** - 模拟真实按钮操作
|
|
||||||
✅ **向后兼容** - 保留原有函数,不影响现有流程
|
|
||||||
✅ **完整日志** - 详细记录每一步操作
|
|
||||||
✅ **超时保护** - 防止无限等待
|
|
||||||
|
|
||||||
该功能已通过实际测试验证,可投入生产使用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档版本:** 1.0
|
|
||||||
**创建日期:** 2026-01-13
|
|
||||||
**作者:** Antigravity AI Assistant
|
|
||||||
**最后更新:** 2026-01-13 21:36
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user