Compare commits

...

19 Commits

Author SHA1 Message Date
ZiWei
64c748d921 Merge branch 'vibe/dev' into feature/organic-extraction 2026-02-03 10:39:44 +08:00
ZiWei
15ff0e9d30 feat: add Bioyond deck imports to resource registration 2026-02-03 10:28:51 +08:00
ZiWei
f8a52860ad Add BIOYOND deck imports and update JSON configurations with new UUIDs for various components 2026-02-03 10:25:47 +08:00
Xuwznln
e30c01d54e Dev backward (#228)
* Workbench example, adjust log level, and ci check (#220)

* TestLatency Return Value Example & gitignore update

* Adjust log level & Add workbench virtual example & Add not action decorator & Add check_mode &

* Add CI Check

* CI Check Fix 1

* CI Check Fix 2

* CI Check Fix 3

* CI Check Fix 4

* CI Check Fix 5

* Upgrade to py 3.11.14; ros 0.7; unilabos 0.10.16

* Update to ROS2 Humble 0.7

* Fix Build 1

* Fix Build 2

* Fix Build 3

* Fix Build 4

* Fix Build 5

* Fix Build 6

* Fix Build 7

* ci(deps): bump actions/configure-pages from 4 to 5 (#222)

Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/upload-artifact from 4 to 6 (#224)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/upload-pages-artifact from 3 to 4 (#225)

Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/checkout from 4 to 6 (#223)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix Build 8

* Fix Build 9

* Fix Build 10

* Fix Build 11

* Fix Build 12

* Fix Build 13

* v0.10.17

(cherry picked from commit 176de521b4)

* CI Check use production mode

* Fix OT2 & ReAdd Virtual Devices

* add msg goal

* transfer liquid handles

* gather query

* add unilabos_class

* Support root node change pos

* save class name when deserialize & protocol execute test

* fix upload workflow json

* workflow upload & set liquid fix & add set liquid with plate

* speed up registry load

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hanhua@dp.tech <2509856570@qq.com>
2026-02-02 23:57:13 +08:00
ZiWei
37ec49f318 Refactor Bioyond resource handling: update warehouse mapping retrieval, add TipBox support, and improve liquid tracking logic. Migrate TipBox creation to bottle_carriers.py for better structure. 2026-01-29 16:31:14 +08:00
ZiWei
6bf57f18c1 Collaboration With Cursor 2026-01-29 11:29:38 +08:00
Xuwznln
400bb073d4 gather query 2026-01-28 13:23:25 +08:00
Xuwznln
3f63c36505 transfer liquid handles 2026-01-28 11:45:45 +08:00
Xuwznln
0ae94f7f3c add msg goal 2026-01-28 09:21:43 +08:00
Xuwznln
7eacae6442 Fix OT2 & ReAdd Virtual Devices 2026-01-28 01:05:32 +08:00
Xuwznln
f7d2cb4b9e CI Check use production mode 2026-01-27 19:59:06 +08:00
Xuwznln
bf980d7248 v0.10.17
(cherry picked from commit 176de521b4)
2026-01-27 19:41:49 +08:00
Xuwznln
27c0544bfc Fix Build 13 2026-01-27 19:36:42 +08:00
Xuwznln
d48e77c9ae Fix Build 12 2026-01-27 19:16:21 +08:00
Xuwznln
e70a5bea66 Fix Build 11 2026-01-27 19:09:39 +08:00
Xuwznln
467d75dc03 Fix Build 10 2026-01-27 17:41:06 +08:00
ZiWei
c4a3be1498 feat: enhance separation_step logic with polling thread management and error handling 2026-01-27 12:37:09 +08:00
ZiWei
e11070315d feat: add separation_step with sensor-motor linkage 2026-01-26 23:34:47 +08:00
ZiWei
50ebcad9d7 feat: add ZDT_X42 motor and XKC sensor drivers 2026-01-22 15:07:32 +08:00
55 changed files with 11634 additions and 1063 deletions

View File

@@ -3,7 +3,7 @@
package:
name: unilabos
version: 0.10.16
version: 0.10.17
source:
path: ../../unilabos
@@ -26,7 +26,7 @@ build:
- cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR
- cp $RECIPE_DIR/../../setup.cfg $SRC_DIR
- cp $RECIPE_DIR/../../setup.py $SRC_DIR
- uv pip install $SRC_DIR
- pip install $SRC_DIR
requirements:
host:
@@ -41,7 +41,6 @@ requirements:
- networkx
- typing_extensions
- websockets
- opentrons_shared_data
- pint
- fastapi
- jinja2
@@ -53,7 +52,7 @@ requirements:
- pymodbus
- matplotlib
- pylibftdi
- uni-lab::unilabos-env ==0.10.16
- uni-lab::unilabos-env ==0.10.17
about:
repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

@@ -2,7 +2,7 @@
package:
name: unilabos-env
version: 0.10.16
version: 0.10.17
build:
noarch: generic
@@ -36,4 +36,4 @@ requirements:
about:
repository: https://github.com/deepmodeling/Uni-Lab-OS
license: GPL-3.0-only
description: "UniLabOS Environment - ROS2 and conda dependencies (for developers: pip install -e .)"
description: "UniLabOS Environment - ROS2 and conda dependencies"

View File

@@ -1,9 +1,9 @@
# unilabos-full: Full package with all features
# unilabos-full: Full package with all features
# Depends on unilabos + complete ROS2 desktop + dev tools
package:
name: unilabos-full
version: 0.10.16
version: 0.10.17
build:
noarch: generic
@@ -11,7 +11,7 @@ build:
requirements:
run:
# Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.10.16
- uni-lab::unilabos ==0.10.17
# Documentation tools
- sphinx
- sphinx_rtd_theme

View File

@@ -0,0 +1,328 @@
---
description: 设备驱动开发规范
globs: ["unilabos/devices/**/*.py"]
---
# 设备驱动开发规范
## 目录结构
```
unilabos/devices/
├── virtual/ # 虚拟设备(用于测试)
│ ├── virtual_stirrer.py
│ └── virtual_centrifuge.py
├── liquid_handling/ # 液体处理设备
├── balance/ # 天平设备
├── hplc/ # HPLC设备
├── pump_and_valve/ # 泵和阀门
├── temperature/ # 温度控制设备
├── workstation/ # 工作站(组合设备)
└── ...
```
## 设备类完整模板
```python
import asyncio
import logging
import time as time_module
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class MyDevice:
"""
设备类描述
Attributes:
device_id: 设备唯一标识
config: 设备配置字典
data: 设备状态数据
"""
_ros_node: BaseROS2DeviceNode
def __init__(
self,
device_id: str = None,
config: Dict[str, Any] = None,
**kwargs
):
"""
初始化设备
Args:
device_id: 设备ID
config: 配置字典
**kwargs: 其他参数
"""
# 兼容不同调用方式
if device_id is None and 'id' in kwargs:
device_id = kwargs.pop('id')
if config is None and 'config' in kwargs:
config = kwargs.pop('config')
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.data = {}
# 从config读取参数
self.port = self.config.get('port') or kwargs.get('port', 'COM1')
self._max_value = self.config.get('max_value', 1000.0)
# 初始化日志
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.logger.info(f"设备 {self.device_id} 已创建")
def post_init(self, ros_node: BaseROS2DeviceNode):
"""
ROS节点注入 - 在ROS节点创建后调用
Args:
ros_node: ROS2设备节点实例
"""
self._ros_node = ros_node
async def initialize(self) -> bool:
"""
初始化设备 - 连接硬件、设置初始状态
Returns:
bool: 初始化是否成功
"""
self.logger.info(f"初始化设备 {self.device_id}")
try:
# 执行硬件初始化
# await self._connect_hardware()
# 设置初始状态
self.data.update({
"status": "待机",
"is_running": False,
"current_value": 0.0,
})
self.logger.info(f"设备 {self.device_id} 初始化完成")
return True
except Exception as e:
self.logger.error(f"初始化失败: {e}")
self.data["status"] = f"错误: {e}"
return False
async def cleanup(self) -> bool:
"""
清理设备 - 断开连接、释放资源
Returns:
bool: 清理是否成功
"""
self.logger.info(f"清理设备 {self.device_id}")
self.data.update({
"status": "离线",
"is_running": False,
})
return True
# ==================== 设备动作 ====================
async def execute_action(
self,
param1: float,
param2: str = "",
**kwargs
) -> bool:
"""
执行设备动作
Args:
param1: 参数1
param2: 参数2可选
Returns:
bool: 动作是否成功
"""
# 类型转换和验证
try:
param1 = float(param1)
except (ValueError, TypeError) as e:
self.logger.error(f"参数类型错误: {e}")
return False
# 参数验证
if param1 > self._max_value:
self.logger.error(f"参数超出范围: {param1} > {self._max_value}")
return False
self.logger.info(f"执行动作: param1={param1}, param2={param2}")
# 更新状态
self.data.update({
"status": "运行中",
"is_running": True,
})
# 执行动作(带进度反馈)
duration = 10.0 # 秒
start_time = time_module.time()
while True:
elapsed = time_module.time() - start_time
remaining = max(0, duration - elapsed)
progress = min(100, (elapsed / duration) * 100)
self.data.update({
"status": f"运行中: {progress:.0f}%",
"remaining_time": remaining,
})
if remaining <= 0:
break
await self._ros_node.sleep(1.0)
# 完成
self.data.update({
"status": "完成",
"is_running": False,
})
self.logger.info("动作执行完成")
return True
# ==================== 状态属性 ====================
@property
def status(self) -> str:
"""设备状态 - 自动发布为ROS Topic"""
return self.data.get("status", "未知")
@property
def is_running(self) -> bool:
"""是否正在运行"""
return self.data.get("is_running", False)
@property
def current_value(self) -> float:
"""当前值"""
return self.data.get("current_value", 0.0)
# ==================== 辅助方法 ====================
def get_device_info(self) -> Dict[str, Any]:
"""获取设备信息"""
return {
"device_id": self.device_id,
"status": self.status,
"is_running": self.is_running,
"current_value": self.current_value,
}
def __str__(self) -> str:
return f"MyDevice({self.device_id}: {self.status})"
```
## 关键规则
### 1. 参数处理
所有动作方法的参数都可能以字符串形式传入,必须进行类型转换:
```python
async def my_action(self, value: float, **kwargs) -> bool:
# 始终进行类型转换
try:
value = float(value)
except (ValueError, TypeError) as e:
self.logger.error(f"参数类型错误: {e}")
return False
```
### 2. vessel 参数处理
vessel 参数可能是字符串ID或字典
```python
def extract_vessel_id(vessel: Union[str, dict]) -> str:
if isinstance(vessel, dict):
return vessel.get("id", "")
return str(vessel) if vessel else ""
```
### 3. 状态更新
使用 `self.data` 字典存储状态,属性读取状态:
```python
# 更新状态
self.data["status"] = "运行中"
self.data["current_speed"] = 300.0
# 读取状态(通过属性)
@property
def status(self) -> str:
return self.data.get("status", "待机")
```
### 4. 异步等待
使用 ROS 节点的 sleep 方法:
```python
# 正确
await self._ros_node.sleep(1.0)
# 避免(除非在纯 Python 测试环境)
await asyncio.sleep(1.0)
```
### 5. 进度反馈
长时间运行的操作需要提供进度反馈:
```python
while remaining > 0:
progress = (elapsed / total_time) * 100
self.data["status"] = f"运行中: {progress:.0f}%"
self.data["remaining_time"] = remaining
await self._ros_node.sleep(1.0)
```
## 虚拟设备
虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录:
- 类名以 `Virtual` 开头
- 文件名以 `virtual_` 开头
- 模拟真实设备的行为和时序
- 使用表情符号增强日志可读性(可选)
## 工作站设备
工作站是组合多个设备的复杂设备:
```python
from unilabos.devices.workstation.workstation_base import WorkstationBase
class MyWorkstation(WorkstationBase):
"""组合工作站"""
async def execute_workflow(self, workflow: Dict[str, Any]) -> bool:
"""执行工作流"""
pass
```
## 设备注册
设备类开发完成后,需要在注册表中注册:
1. 创建/编辑 `unilabos/registry/devices/my_category.yaml`
2. 添加设备配置(参考 `virtual_device.yaml`
3. 运行 `--complete_registry` 自动生成 schema

View File

@@ -0,0 +1,240 @@
---
description: 协议编译器开发规范
globs: ["unilabos/compile/**/*.py"]
---
# 协议编译器开发规范
## 概述
协议编译器负责将高级实验操作(如 Stir、Add、Filter编译为设备可执行的动作序列。
## 文件命名
- 位置: `unilabos/compile/`
- 命名: `{operation}_protocol.py`
- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py`
## 协议函数模板
```python
from typing import List, Dict, Any, Union
import networkx as nx
import logging
from .utils.unit_parser import parse_time_input
from .utils.vessel_parser import extract_vessel_id
logger = logging.getLogger(__name__)
def generate_{operation}_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
param1: Union[str, float] = "0",
param2: float = 0.0,
**kwargs
) -> List[Dict[str, Any]]:
"""
生成{操作}协议序列
Args:
G: 物理拓扑图 (NetworkX DiGraph)
vessel: 容器ID或Resource字典
param1: 参数1支持字符串单位如 "5 min"
param2: 参数2
**kwargs: 其他参数
Returns:
List[Dict]: 动作序列
Raises:
ValueError: 参数无效时
"""
# 1. 提取 vessel_id
vessel_id = extract_vessel_id(vessel)
# 2. 验证参数
if not vessel_id:
raise ValueError("vessel 参数不能为空")
if vessel_id not in G.nodes():
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
# 3. 解析参数(支持单位)
parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0
# 4. 查找设备
device_id = find_connected_device(G, vessel_id, device_type="my_device")
# 5. 生成动作序列
action_sequence = []
action = {
"device_id": device_id,
"action_name": "my_action",
"action_kwargs": {
"vessel": {"id": vessel_id}, # 始终使用字典格式
"param1": float(parsed_param1),
"param2": float(param2),
}
}
action_sequence.append(action)
logger.info(f"生成协议: {len(action_sequence)} 个动作")
return action_sequence
def find_connected_device(
G: nx.DiGraph,
vessel_id: str,
device_type: str = ""
) -> str:
"""
查找与容器相连的设备
Args:
G: 拓扑图
vessel_id: 容器ID
device_type: 设备类型关键字
Returns:
str: 设备ID
"""
# 查找所有匹配类型的设备
device_nodes = []
for node in G.nodes():
node_class = G.nodes[node].get('class', '') or ''
if device_type.lower() in node_class.lower():
device_nodes.append(node)
# 检查连接
if vessel_id and device_nodes:
for device in device_nodes:
if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device):
return device
# 返回第一个可用设备
if device_nodes:
return device_nodes[0]
# 默认设备
return f"{device_type}_1"
```
## 关键规则
### 1. vessel 参数处理
vessel 参数可能是字符串或字典,需要统一处理:
```python
def extract_vessel_id(vessel: Union[str, dict]) -> str:
"""提取vessel_id"""
if isinstance(vessel, dict):
# 可能是 {"id": "xxx"} 或完整 Resource 对象
return vessel.get("id", list(vessel.values())[0].get("id", ""))
return str(vessel) if vessel else ""
```
### 2. action_kwargs 中的 vessel
始终使用 `{"id": vessel_id}` 格式传递 vessel
```python
# 正确
"action_kwargs": {
"vessel": {"id": vessel_id}, # 字符串ID包装为字典
}
# 避免
"action_kwargs": {
"vessel": vessel_resource, # 不要传递完整 Resource 对象
}
```
### 3. 单位解析
使用 `parse_time_input` 解析时间参数:
```python
from .utils.unit_parser import parse_time_input
# 支持格式: "5 min", "1 h", "300", "1.5 hours"
time_seconds = parse_time_input("5 min") # -> 300.0
time_seconds = parse_time_input(120) # -> 120.0
time_seconds = parse_time_input("1 h") # -> 3600.0
```
### 4. 参数验证
所有参数必须进行验证和类型转换:
```python
# 验证范围
if speed < 10.0 or speed > 1500.0:
logger.warning(f"速度 {speed} 超出范围,修正为 300")
speed = 300.0
# 类型转换
param = float(param) if not isinstance(param, (int, float)) else param
```
### 5. 日志记录
使用项目日志记录器:
```python
logger = logging.getLogger(__name__)
def generate_protocol(...):
logger.info(f"开始生成协议...")
logger.debug(f"参数: vessel={vessel_id}, time={time}")
logger.warning(f"参数修正: {old_value} -> {new_value}")
```
## 便捷函数
为常用操作提供便捷函数:
```python
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
speed: float = 300.0) -> List[Dict[str, Any]]:
"""短时间搅拌30秒"""
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
time: str = "5 min") -> List[Dict[str, Any]]:
"""剧烈搅拌"""
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
```
## 测试函数
每个协议文件应包含测试函数:
```python
def test_{operation}_protocol():
"""测试协议生成"""
# 测试参数处理
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
vessel_id = extract_vessel_id(vessel_dict)
assert vessel_id == "flask_1"
# 测试单位解析
time_s = parse_time_input("5 min")
assert time_s == 300.0
if __name__ == "__main__":
test_{operation}_protocol()
```
## 现有协议参考
- `stir_protocol.py` - 搅拌操作
- `add_protocol.py` - 添加物料
- `filter_protocol.py` - 过滤操作
- `heatchill_protocol.py` - 加热/冷却
- `separate_protocol.py` - 分离操作
- `evaporate_protocol.py` - 蒸发操作

View File

@@ -0,0 +1,319 @@
---
description: 注册表配置规范 (YAML)
globs: ["unilabos/registry/**/*.yaml"]
---
# 注册表配置规范
## 概述
注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。
## 目录结构
```
unilabos/registry/
├── devices/ # 设备类型注册
│ ├── virtual_device.yaml
│ ├── liquid_handler.yaml
│ └── ...
├── device_comms/ # 通信设备配置
│ ├── communication_devices.yaml
│ └── modbus_ioboard.yaml
└── resources/ # 资源类型注册
├── bioyond/
├── organic/
├── opentrons/
└── ...
```
## 设备注册表格式
### 基本结构
```yaml
device_type_id:
# 基本信息
description: "设备描述"
version: "1.0.0"
category:
- category_name
icon: "icon_device.webp"
# 类配置
class:
module: "unilabos.devices.my_module:MyClass"
type: python
# 状态类型(属性 -> ROS消息类型
status_types:
status: String
temperature: Float64
is_running: Bool
# 动作映射
action_value_mappings:
action_name:
type: UniLabJsonCommand # 或 UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
handles: {}
```
### action_value_mappings 详细格式
```yaml
action_value_mappings:
# 同步动作
my_sync_action:
type: UniLabJsonCommand
goal:
param1: param1
param2: param2
feedback: {}
result:
success: success
message: message
goal_default:
param1: 0.0
param2: ""
handles: {}
placeholder_keys:
device_param: unilabos_devices # 设备选择器
resource_param: unilabos_resources # 资源选择器
schema:
title: "动作名称参数"
description: "动作描述"
type: object
properties:
goal:
type: object
properties:
param1:
type: number
param2:
type: string
required:
- param1
feedback: {}
result:
type: object
properties:
success:
type: boolean
message:
type: string
required:
- goal
# 异步动作
my_async_action:
type: UniLabJsonCommandAsync
goal: {}
feedback:
progress: progress
current_status: status
result:
success: success
schema: {...}
```
### 自动生成的动作
以 `auto-` 开头的动作由系统自动生成:
```yaml
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
auto-cleanup:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
```
### handles 配置
用于工作流编辑器中的数据流连接:
```yaml
handles:
input:
- handler_key: "input_resource"
data_type: "resource"
label: "输入资源"
data_source: "handle"
data_key: "resources"
output:
- handler_key: "output_labware"
data_type: "resource"
label: "输出器皿"
data_source: "executor"
data_key: "created_resource.@flatten"
```
## 资源注册表格式
```yaml
resource_type_id:
description: "资源描述"
version: "1.0.0"
category:
- category_name
icon: ""
handles: []
init_param_schema: {}
class:
module: "unilabos.resources.my_module:MyResource"
type: pylabrobot # 或 python
```
### PyLabRobot 资源示例
```yaml
BIOYOND_Electrolyte_6VialCarrier:
category:
- bottle_carriers
- bioyond
class:
module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier"
type: pylabrobot
version: "1.0.0"
```
## 状态类型映射
Python 类型到 ROS 消息类型的映射:
| Python 类型 | ROS 消息类型 |
|------------|-------------|
| `str` | `String` |
| `bool` | `Bool` |
| `int` | `Int64` |
| `float` | `Float64` |
| `list` | `String` (序列化) |
| `dict` | `String` (序列化) |
## 自动完善注册表
使用 `--complete_registry` 参数自动生成 schema
```bash
python -m unilabos.app.main --complete_registry
```
这会:
1. 扫描设备类的方法签名
2. 自动生成 `auto-` 前缀的动作
3. 生成 JSON Schema
4. 更新 YAML 文件
## 验证规则
1. **device_type_id** 必须唯一
2. **module** 路径必须正确可导入
3. **status_types** 的类型必须是有效的 ROS 消息类型
4. **schema** 必须是有效的 JSON Schema
## 示例:完整设备配置
```yaml
virtual_stirrer:
category:
- virtual_device
description: "虚拟搅拌器设备"
version: "1.0.0"
icon: "icon_stirrer.webp"
handles: []
init_param_schema: {}
class:
module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer"
type: python
status_types:
status: String
operation_mode: String
current_speed: Float64
is_stirring: Bool
remaining_time: Float64
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema:
title: "initialize参数"
type: object
properties:
goal:
type: object
properties: {}
feedback: {}
result: {}
required:
- goal
stir:
type: UniLabJsonCommandAsync
goal:
stir_time: stir_time
stir_speed: stir_speed
settling_time: settling_time
feedback:
current_speed: current_speed
remaining_time: remaining_time
result:
success: success
goal_default:
stir_time: 60.0
stir_speed: 300.0
settling_time: 30.0
handles: {}
schema:
title: "stir参数"
description: "搅拌操作"
type: object
properties:
goal:
type: object
properties:
stir_time:
type: number
description: "搅拌时间(秒)"
stir_speed:
type: number
description: "搅拌速度RPM"
settling_time:
type: number
description: "沉降时间(秒)"
required:
- stir_time
- stir_speed
feedback:
type: object
properties:
current_speed:
type: number
remaining_time:
type: number
result:
type: object
properties:
success:
type: boolean
required:
- goal
```

View File

@@ -0,0 +1,233 @@
---
description: ROS 2 集成开发规范
globs: ["unilabos/ros/**/*.py", "**/*_node.py"]
---
# ROS 2 集成开发规范
## 概述
Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。
## 核心组件
### BaseROS2DeviceNode
设备节点基类,提供:
- ROS Topic 自动发布(状态属性)
- Action Server 自动创建(设备动作)
- 资源管理服务
- 异步任务调度
```python
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
```
### 消息转换器
```python
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg_with_mapping,
msg_converter_manager,
ros_action_to_json_schema,
ros_message_to_json_schema,
)
```
## 设备与 ROS 集成
### post_init 方法
设备类必须实现 `post_init` 方法接收 ROS 节点:
```python
class MyDevice:
_ros_node: BaseROS2DeviceNode
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点注入"""
self._ros_node = ros_node
```
### 状态属性发布
设备的 `@property` 属性会自动发布为 ROS Topic
```python
class MyDevice:
@property
def temperature(self) -> float:
return self._temperature
# 自动发布到 /{namespace}/temperature Topic
```
### Topic 配置装饰器
```python
from unilabos.utils.decorator import topic_config
class MyDevice:
@property
@topic_config(period=1.0, print_publish=False, qos=10)
def fast_data(self) -> float:
"""高频数据 - 每秒发布一次"""
return self._fast_data
@property
@topic_config(period=5.0)
def slow_data(self) -> str:
"""低频数据 - 每5秒发布一次"""
return self._slow_data
```
### 订阅装饰器
```python
from unilabos.utils.decorator import subscribe
class MyDevice:
@subscribe(topic="/external/sensor_data", qos=10)
def on_sensor_data(self, msg):
"""订阅外部Topic"""
self._sensor_value = msg.data
```
## 异步操作
### 使用 ROS 节点睡眠
```python
# 推荐使用ROS节点的睡眠方法
await self._ros_node.sleep(1.0)
# 不推荐直接使用asyncio可能导致回调阻塞
await asyncio.sleep(1.0)
```
### 获取事件循环
```python
from unilabos.ros.x.rclpyx import get_event_loop
loop = get_event_loop()
```
## 消息类型
### unilabos_msgs 包
```python
from unilabos_msgs.msg import Resource
from unilabos_msgs.srv import (
ResourceAdd,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
)
from unilabos_msgs.action import SendCmd
```
### Resource 消息结构
```python
Resource:
id: str
name: str
category: str
type: str
parent: str
children: List[str]
config: str # JSON字符串
data: str # JSON字符串
sample_id: str
pose: Pose
```
## 日志适配器
```python
from unilabos.utils.log import info, debug, warning, error, trace
class MyDevice:
def __init__(self):
# 创建设备专属日志器
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
```
ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。
## Action Server
设备动作自动创建为 ROS Action Server
```yaml
# 在注册表中配置
action_value_mappings:
my_action:
type: UniLabJsonCommandAsync # 异步Action
goal: {...}
feedback: {...}
result: {...}
```
### Action 类型
- **UniLabJsonCommand**: 同步动作
- **UniLabJsonCommandAsync**: 异步动作支持feedback
## 服务客户端
```python
from rclpy.client import Client
# 调用其他节点的服务
response = await self._ros_node.call_service(
service_name="/other_node/service",
request=MyServiceRequest(...)
)
```
## 命名空间
设备节点使用命名空间隔离:
```
/{device_id}/ # 设备命名空间
/{device_id}/status # 状态Topic
/{device_id}/temperature # 温度Topic
/{device_id}/my_action # 动作Server
```
## 调试
### 查看 Topic
```bash
ros2 topic list
ros2 topic echo /{device_id}/status
```
### 查看 Action
```bash
ros2 action list
ros2 action info /{device_id}/my_action
```
### 查看 Service
```bash
ros2 service list
ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList
```
## 最佳实践
1. **状态属性命名**: 使用蛇形命名法snake_case
2. **Topic 频率**: 根据数据变化频率调整,避免过高频率
3. **Action 反馈**: 长时间操作提供进度反馈
4. **错误处理**: 使用 try-except 捕获并记录错误
5. **资源清理**: 在 cleanup 方法中正确清理资源

View File

@@ -0,0 +1,357 @@
---
description: 测试开发规范
globs: ["tests/**/*.py", "**/test_*.py"]
---
# 测试开发规范
## 目录结构
```
tests/
├── __init__.py
├── devices/ # 设备测试
│ └── liquid_handling/
│ └── test_transfer_liquid.py
├── resources/ # 资源测试
│ ├── test_bottle_carrier.py
│ └── test_resourcetreeset.py
├── ros/ # ROS消息测试
│ └── msgs/
│ ├── test_basic.py
│ ├── test_conversion.py
│ └── test_mapping.py
└── workflow/ # 工作流测试
└── merge_workflow.py
```
## 测试框架
使用 pytest 作为测试框架:
```bash
# 运行所有测试
pytest tests/
# 运行特定测试文件
pytest tests/resources/test_bottle_carrier.py
# 运行特定测试函数
pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier
# 显示详细输出
pytest -v tests/
# 显示打印输出
pytest -s tests/
```
## 测试文件模板
```python
import pytest
from typing import List, Dict, Any
# 导入被测试的模块
from unilabos.resources.bioyond.bottle_carriers import (
BIOYOND_Electrolyte_6VialCarrier,
)
from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Solid_Vial,
)
class TestBottleCarrier:
"""BottleCarrier 测试类"""
def setup_method(self):
"""每个测试方法前执行"""
self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier")
def teardown_method(self):
"""每个测试方法后执行"""
pass
def test_carrier_creation(self):
"""测试载架创建"""
assert self.carrier.name == "test_carrier"
assert len(self.carrier.sites) == 6
def test_bottle_placement(self):
"""测试瓶子放置"""
bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle")
# 测试逻辑...
assert bottle.name == "test_bottle"
def test_standalone_function():
"""独立测试函数"""
result = some_function()
assert result is True
# 参数化测试
@pytest.mark.parametrize("input,expected", [
("5 min", 300.0),
("1 h", 3600.0),
("120", 120.0),
(60, 60.0),
])
def test_time_parsing(input, expected):
"""测试时间解析"""
from unilabos.compile.utils.unit_parser import parse_time_input
assert parse_time_input(input) == expected
# 异常测试
def test_invalid_input_raises_error():
"""测试无效输入抛出异常"""
with pytest.raises(ValueError) as exc_info:
invalid_function("bad_input")
assert "invalid" in str(exc_info.value).lower()
# 跳过条件测试
@pytest.mark.skipif(
not os.environ.get("ROS_DISTRO"),
reason="需要ROS环境"
)
def test_ros_feature():
"""需要ROS环境的测试"""
pass
```
## 设备测试
### 虚拟设备测试
```python
import pytest
import asyncio
from unittest.mock import MagicMock, AsyncMock
from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer
class TestVirtualStirrer:
"""VirtualStirrer 测试"""
@pytest.fixture
def stirrer(self):
"""创建测试用搅拌器"""
device = VirtualStirrer(
device_id="test_stirrer",
config={"max_speed": 1500.0, "min_speed": 50.0}
)
# Mock ROS节点
mock_node = MagicMock()
mock_node.sleep = AsyncMock(return_value=None)
device.post_init(mock_node)
return device
@pytest.mark.asyncio
async def test_initialize(self, stirrer):
"""测试初始化"""
result = await stirrer.initialize()
assert result is True
assert stirrer.status == "待机中"
@pytest.mark.asyncio
async def test_stir_action(self, stirrer):
"""测试搅拌动作"""
await stirrer.initialize()
result = await stirrer.stir(
stir_time=5.0,
stir_speed=300.0,
settling_time=2.0
)
assert result is True
assert stirrer.operation_mode == "Completed"
@pytest.mark.asyncio
async def test_stir_invalid_speed(self, stirrer):
"""测试无效速度"""
await stirrer.initialize()
# 速度超出范围
result = await stirrer.stir(
stir_time=5.0,
stir_speed=2000.0, # 超过max_speed
settling_time=0.0
)
assert result is False
assert "错误" in stirrer.status
```
### 异步测试配置
```python
# conftest.py
import pytest
import asyncio
@pytest.fixture(scope="session")
def event_loop():
"""创建事件循环"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
```
## 资源测试
```python
import pytest
from unilabos.resources.resource_tracker import (
ResourceTreeSet,
ResourceTreeInstance,
)
def test_resource_tree_creation():
"""测试资源树创建"""
tree_set = ResourceTreeSet()
# 添加资源
resource = {"id": "res_1", "name": "Resource 1"}
tree_set.add_resource(resource)
# 验证
assert len(tree_set.all_nodes) == 1
assert tree_set.get_resource("res_1") is not None
def test_resource_tree_merge():
"""测试资源树合并"""
local_set = ResourceTreeSet()
remote_set = ResourceTreeSet()
# 设置数据...
local_set.merge_remote_resources(remote_set)
# 验证合并结果...
```
## ROS 消息测试
```python
import pytest
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg_with_mapping,
msg_converter_manager,
)
def test_message_conversion():
"""测试消息转换"""
# Python -> ROS
python_data = {"id": "test", "value": 42}
ros_msg = convert_to_ros_msg(python_data, MyMsgType)
assert ros_msg.id == "test"
assert ros_msg.value == 42
# ROS -> Python
result = convert_from_ros_msg_with_mapping(ros_msg, mapping)
assert result["id"] == "test"
```
## 协议测试
```python
import pytest
import networkx as nx
from unilabos.compile.stir_protocol import (
generate_stir_protocol,
extract_vessel_id,
)
@pytest.fixture
def topology_graph():
"""创建测试拓扑图"""
G = nx.DiGraph()
G.add_node("flask_1", **{"class": "flask"})
G.add_node("stirrer_1", **{"class": "virtual_stirrer"})
G.add_edge("stirrer_1", "flask_1")
return G
def test_generate_stir_protocol(topology_graph):
"""测试搅拌协议生成"""
actions = generate_stir_protocol(
G=topology_graph,
vessel="flask_1",
time="5 min",
stir_speed=300.0
)
assert len(actions) == 1
assert actions[0]["device_id"] == "stirrer_1"
assert actions[0]["action_name"] == "stir"
def test_extract_vessel_id():
"""测试vessel_id提取"""
# 字典格式
assert extract_vessel_id({"id": "flask_1"}) == "flask_1"
# 字符串格式
assert extract_vessel_id("flask_2") == "flask_2"
# 空值
assert extract_vessel_id("") == ""
```
## 测试标记
```python
# 慢速测试
@pytest.mark.slow
def test_long_running():
pass
# 需要网络
@pytest.mark.network
def test_network_call():
pass
# 需要ROS
@pytest.mark.ros
def test_ros_feature():
pass
```
运行特定标记的测试:
```bash
pytest -m "not slow" # 排除慢速测试
pytest -m ros # 仅ROS测试
```
## 覆盖率
```bash
# 生成覆盖率报告
pytest --cov=unilabos tests/
# HTML报告
pytest --cov=unilabos --cov-report=html tests/
```
## 最佳实践
1. **测试命名**: `test_{功能}_{场景}_{预期结果}`
2. **独立性**: 每个测试独立运行,不依赖其他测试
3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务
4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码
5. **fixtures**: 使用 fixtures 共享测试设置
6. **断言清晰**: 每个断言只验证一件事

View File

@@ -0,0 +1,353 @@
---
description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则
globs: ["**/*.py", "**/*.yaml", "**/*.json"]
---
# Uni-Lab-OS 项目开发规范
## 项目概述
Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。
## 技术栈
- **Python 3.11** - 核心开发语言
- **ROS 2** - 设备通信中间件 (rclpy)
- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge)
- **FastAPI** - Web API 服务
- **WebSocket** - 实时通信
- **NetworkX** - 拓扑图管理
- **YAML** - 配置和注册表定义
- **PyLabRobot** - 实验室自动化库集成
- **pytest** - 测试框架
- **asyncio** - 异步编程
## 项目结构
```
unilabos/
├── app/ # 应用入口、Web服务、后端
├── compile/ # 协议编译器 (stir, add, filter 等)
├── config/ # 配置管理
├── devices/ # 设备驱动 (真实/虚拟)
├── device_comms/ # 设备通信协议
├── device_mesh/ # 3D网格和可视化
├── registry/ # 设备和资源类型注册表 (YAML)
├── resources/ # 资源定义
├── ros/ # ROS 2 集成
├── utils/ # 工具函数
└── workflow/ # 工作流管理
```
## 代码规范
### Python 风格
1. **类型注解**:所有函数必须使用类型注解
```python
def transfer_liquid(
source: str,
destination: str,
volume: float,
**kwargs
) -> List[Dict[str, Any]]:
```
2. **Docstring**:使用 Google 风格的文档字符串
```python
def initialize(self) -> bool:
"""
初始化设备
Returns:
bool: 初始化是否成功
"""
```
3. **导入顺序**
- 标准库
- 第三方库
- ROS 相关 (rclpy, unilabos_msgs)
- 项目内部模块
### 异步编程
1. 设备操作方法使用 `async def`
2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()`
3. 长时间运行操作需提供进度反馈
```python
async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool:
"""执行搅拌操作"""
start_time = time_module.time()
while True:
elapsed = time_module.time() - start_time
remaining = max(0, stir_time - elapsed)
self.data.update({
"remaining_time": remaining,
"status": f"搅拌中: {stir_speed} RPM"
})
if remaining <= 0:
break
await self._ros_node.sleep(1.0)
return True
```
### 日志规范
使用项目自定义日志系统:
```python
from unilabos.utils.log import logger, info, debug, warning, error, trace
# 在设备类中使用
self.logger = logging.getLogger(f"DeviceName.{self.device_id}")
self.logger.info("设备初始化完成")
```
## 设备驱动开发
### 设备类结构
```python
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class MyDevice:
"""设备驱动类"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.data = {} # 设备状态数据
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点注入"""
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化设备"""
pass
async def cleanup(self) -> bool:
"""清理设备"""
pass
# 状态属性 - 自动发布为 ROS Topic
@property
def status(self) -> str:
return self.data.get("status", "待机")
```
### 状态属性装饰器
```python
from unilabos.utils.decorator import topic_config
class MyDevice:
@property
@topic_config(period=1.0, qos=10) # 每秒发布一次
def temperature(self) -> float:
return self._temperature
```
### 虚拟设备
虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py`
## 注册表配置
### 设备注册表 (YAML)
位置: `unilabos/registry/devices/*.yaml`
```yaml
my_device_type:
category:
- my_category
description: "设备描述"
version: "1.0.0"
class:
module: "unilabos.devices.my_device:MyDevice"
type: python
status_types:
status: String
temperature: Float64
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
```
### 资源注册表 (YAML)
位置: `unilabos/registry/resources/**/*.yaml`
```yaml
my_container:
category:
- container
class:
module: "unilabos.resources.my_resource:MyContainer"
type: pylabrobot
version: "1.0.0"
```
## 协议编译器
位置: `unilabos/compile/*_protocol.py`
### 协议生成函数模板
```python
from typing import List, Dict, Any, Union
import networkx as nx
def generate_my_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
param1: float = 0.0,
**kwargs
) -> List[Dict[str, Any]]:
"""
生成操作协议序列
Args:
G: 物理拓扑图
vessel: 容器ID或字典
param1: 参数1
Returns:
List[Dict]: 动作序列
"""
# 提取vessel_id
vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "")
# 查找设备
device_id = find_connected_device(G, vessel_id)
# 生成动作
action_sequence = [{
"device_id": device_id,
"action_name": "my_action",
"action_kwargs": {
"vessel": {"id": vessel_id},
"param1": float(param1)
}
}]
return action_sequence
```
## 测试规范
### 测试文件位置
- 单元测试: `tests/` 目录
- 设备测试: `tests/devices/`
- 资源测试: `tests/resources/`
- ROS消息测试: `tests/ros/msgs/`
### 测试命名
```python
# tests/devices/my_device/test_my_device.py
import pytest
def test_device_initialization():
"""测试设备初始化"""
pass
def test_device_action():
"""测试设备动作"""
pass
```
## 错误处理
```python
from unilabos.utils.exception import UniLabException
try:
result = await device.execute_action()
except ValueError as e:
self.logger.error(f"参数错误: {e}")
self.data["status"] = "错误: 参数无效"
return False
except Exception as e:
self.logger.error(f"执行失败: {e}")
raise
```
## 配置管理
```python
from unilabos.config.config import BasicConfig, HTTPConfig
# 读取配置
port = BasicConfig.port
is_host = BasicConfig.is_host_mode
# 配置文件: local_config.py
```
## 常用工具
### 单例模式
```python
from unilabos.utils.decorator import singleton
@singleton
class MyManager:
pass
```
### 类型检查
```python
from unilabos.utils.type_check import NoAliasDumper
yaml.dump(data, f, Dumper=NoAliasDumper)
```
### 导入管理
```python
from unilabos.utils.import_manager import get_class
device_class = get_class("unilabos.devices.my_device:MyDevice")
```
## Git 提交规范
提交信息格式:
```
<type>(<scope>): <subject>
<body>
```
类型:
- `feat`: 新功能
- `fix`: 修复bug
- `docs`: 文档更新
- `refactor`: 重构
- `test`: 测试相关
- `chore`: 构建/工具相关
示例:
```
feat(devices): 添加虚拟搅拌器设备
- 实现VirtualStirrer类
- 支持定时搅拌和持续搅拌模式
- 添加速度验证逻辑
```

188
.cursorignore Normal file
View File

@@ -0,0 +1,188 @@
# ============================================================
# Uni-Lab-OS Cursor Ignore 配置,控制 Cursor AI 的文件索引范围
# ============================================================
# ==================== 敏感配置文件 ====================
# 本地配置(可能包含密钥)
**/local_config.py
test_config.py
local_test*.py
# 环境变量和密钥
.env
.env.*
**/.certs/
*.pem
*.key
credentials.json
secrets.yaml
# ==================== 二进制和 3D 模型文件 ====================
# 3D 模型文件(无需索引)
*.stl
*.dae
*.glb
*.gltf
*.obj
*.fbx
*.blend
# URDF/Xacro 机器人描述文件大型XML
*.xacro
# 图片文件
*.png
*.jpg
*.jpeg
*.gif
*.webp
*.ico
*.svg
*.bmp
# 压缩包
*.zip
*.tar
*.tar.gz
*.tgz
*.bz2
*.rar
*.7z
# ==================== Python 生成文件 ====================
__pycache__/
*.py[cod]
*$py.class
*.so
*.pyd
*.egg
*.egg-info/
.eggs/
dist/
build/
*.manifest
*.spec
# ==================== IDE 和编辑器 ====================
.idea/
.vscode/
*.swp
*.swo
*~
.#*
# ==================== 测试和覆盖率 ====================
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
# ==================== 虚拟环境 ====================
.venv/
venv/
env/
ENV/
# ==================== ROS 2 生成文件 ====================
# ROS 构建目录
build/
install/
log/
logs/
devel/
# ROS 消息生成
msg_gen/
srv_gen/
msg/*Action.msg
msg/*ActionFeedback.msg
msg/*ActionGoal.msg
msg/*ActionResult.msg
msg/*Feedback.msg
msg/*Goal.msg
msg/*Result.msg
msg/_*.py
srv/_*.py
build_isolated/
devel_isolated/
# ROS 动态配置
*.cfgc
/cfg/cpp/
/cfg/*.py
# ==================== 项目特定目录 ====================
# 工作数据目录
unilabos_data/
# 临时和输出目录
temp/
output/
cursor_docs/
configs/
# 文档构建
docs/_build/
/site
# ==================== 大型数据文件 ====================
# 点云数据
*.pcd
# GraphML 图形文件
*.graphml
# 日志文件
*.log
# 数据库
*.sqlite3
*.db
# Jupyter 检查点
.ipynb_checkpoints/
# ==================== 设备网格资源 ====================
# 3D 网格文件目录(包含大量 STL/DAE 文件)
unilabos/device_mesh/devices/**/*.stl
unilabos/device_mesh/devices/**/*.dae
unilabos/device_mesh/resources/**/*.stl
unilabos/device_mesh/resources/**/*.glb
unilabos/device_mesh/resources/**/*.xacro
# RViz 配置
*.rviz
# ==================== 系统文件 ====================
.DS_Store
Thumbs.db
desktop.ini
# ==================== 锁文件 ====================
poetry.lock
Pipfile.lock
pdm.lock
package-lock.json
yarn.lock
# ==================== 类型检查缓存 ====================
.mypy_cache/
.dmypy.json
.pytype/
.pyre/
pyrightconfig.json
# ==================== 其他 ====================
# Catkin
CATKIN_IGNORE
# Eclipse/Qt
.project
.cproject
CMakeLists.txt.user
*.user
qtcreator-*

View File

@@ -47,7 +47,7 @@ jobs:
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 -e .
uv pip install .
- name: Run check mode (complete_registry)
run: |

View File

@@ -133,12 +133,29 @@ jobs:
echo "Building unilabos-env (conda environment dependencies)..."
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
- name: Upload unilabos-env to Anaconda.org (if enabled)
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
run: |
echo "Uploading unilabos-env to uni-lab organization..."
for package in $(find ./output -name "unilabos-env*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done
- name: Build unilabos (with pip package)
if: steps.should_build.outputs.should_build == 'true'
run: |
echo "Building unilabos package..."
# 如果已上传到 Anaconda从 uni-lab channel 获取 unilabos-env否则从本地 output 获取
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
- name: Upload unilabos to Anaconda.org (if enabled)
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
run: |
echo "Uploading unilabos to uni-lab organization..."
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done
- name: Build unilabos-full - Only when explicitly requested
if: |
steps.should_build.outputs.should_build == 'true' &&
@@ -147,6 +164,17 @@ jobs:
echo "Building unilabos-full package on ${{ matrix.platform }}..."
rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
- name: Upload unilabos-full to Anaconda.org (if enabled)
if: |
steps.should_build.outputs.should_build == 'true' &&
github.event.inputs.build_full == 'true' &&
github.event.inputs.upload_to_anaconda == 'true'
run: |
echo "Uploading unilabos-full to uni-lab organization..."
for package in $(find ./output -name "unilabos-full*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done
- name: List built packages
if: steps.should_build.outputs.should_build == 'true'
run: |
@@ -173,11 +201,3 @@ jobs:
path: conda-packages-temp
if-no-files-found: warn
retention-days: 30
- name: Upload to Anaconda.org (uni-lab organization)
if: github.event.inputs.upload_to_anaconda == 'true'
run: |
for package in $(find ./output -name "*.conda"); do
echo "Uploading $package to uni-lab organization..."
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done

View File

@@ -439,6 +439,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
1. 访问 Web 界面,进入"仪器耗材"模块
2. 在"仪器设备"区域找到并添加上述设备
3. 在"物料耗材"区域找到并添加容器
4. 在workstation中配置protocol_type包含PumpTransferProtocol
![添加Protocol类型](image/add_protocol.png)
![物料列表](image/material.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,6 +1,6 @@
package:
name: ros-humble-unilabos-msgs
version: 0.10.16
version: 0.10.17
source:
path: ../../unilabos_msgs
target_directory: src

View File

@@ -1,6 +1,6 @@
package:
name: unilabos
version: "0.10.16"
version: "0.10.17"
source:
path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup(
name=package_name,
version='0.10.16',
version='0.10.17',
packages=find_packages(),
include_package_data=True,
install_requires=['setuptools'],

213
tests/workflow/test.json Normal file
View File

@@ -0,0 +1,213 @@
{
"workflow": [
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines",
"targets": "Liquid_1",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines",
"targets": "Liquid_2",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines",
"targets": "Liquid_3",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_2",
"targets": "Liquid_4",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_2",
"targets": "Liquid_5",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_2",
"targets": "Liquid_6",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_3",
"targets": "dest_set",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_3",
"targets": "dest_set_2",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "cell_lines_3",
"targets": "dest_set_3",
"asp_vol": 100.0,
"dis_vol": 74.75,
"asp_flow_rate": 94.0,
"dis_flow_rate": 95.5
}
}
],
"reagent": {
"Liquid_1": {
"slot": 1,
"well": [
"A4",
"A7",
"A10"
],
"labware": "rep 1"
},
"Liquid_4": {
"slot": 1,
"well": [
"A4",
"A7",
"A10"
],
"labware": "rep 1"
},
"dest_set": {
"slot": 1,
"well": [
"A4",
"A7",
"A10"
],
"labware": "rep 1"
},
"Liquid_2": {
"slot": 2,
"well": [
"A3",
"A5",
"A8"
],
"labware": "rep 2"
},
"Liquid_5": {
"slot": 2,
"well": [
"A3",
"A5",
"A8"
],
"labware": "rep 2"
},
"dest_set_2": {
"slot": 2,
"well": [
"A3",
"A5",
"A8"
],
"labware": "rep 2"
},
"Liquid_3": {
"slot": 3,
"well": [
"A4",
"A6",
"A10"
],
"labware": "rep 3"
},
"Liquid_6": {
"slot": 3,
"well": [
"A4",
"A6",
"A10"
],
"labware": "rep 3"
},
"dest_set_3": {
"slot": 3,
"well": [
"A4",
"A6",
"A10"
],
"labware": "rep 3"
},
"cell_lines": {
"slot": 4,
"well": [
"A1",
"A3",
"A5"
],
"labware": "DRUG + YOYO-MEDIA"
},
"cell_lines_2": {
"slot": 4,
"well": [
"A1",
"A3",
"A5"
],
"labware": "DRUG + YOYO-MEDIA"
},
"cell_lines_3": {
"slot": 4,
"well": [
"A1",
"A3",
"A5"
],
"labware": "DRUG + YOYO-MEDIA"
}
}
}

View File

@@ -1 +1 @@
__version__ = "0.10.16"
__version__ = "0.10.17"

View File

@@ -359,9 +359,7 @@ class HTTPClient:
Returns:
Dict: API响应数据包含 code 和 data (uuid, name)
"""
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
payload = {
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
"name": name,
"data": {
"workflow_uuid": workflow_uuid,

View File

File diff suppressed because it is too large Load Diff

View File

@@ -30,9 +30,30 @@ from pylabrobot.liquid_handling.standard import (
ResourceMove,
ResourceDrop,
)
from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack
from pylabrobot.resources import (
ResourceHolder,
ResourceStack,
Tip,
Deck,
Plate,
Well,
TipRack,
Resource,
Container,
Coordinate,
TipSpot,
Trash,
PlateAdapter,
TubeRack,
)
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
LiquidHandlerAbstract,
SimpleReturn,
SetLiquidReturn,
SetLiquidFromPlateReturn,
)
from unilabos.registry.placeholder_type import ResourceSlot
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -80,6 +101,7 @@ class PRCXI9300Deck(Deck):
self.slots[slot - 1] = resource
super().assign_child_resource(resource, location=self.slot_locations[slot - 1])
class PRCXI9300Container(Plate):
"""PRCXI 9300 的专用 Container 类,继承自 Plate用于槽位定位和未知模块。
@@ -108,20 +130,29 @@ class PRCXI9300Container(Plate):
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
data = super().serialize_state()
data.update(self._unilabos_state)
return data
return data
class PRCXI9300Plate(Plate):
"""
"""
专用孔板类:
1. 继承自 PLR 原生 Plate保留所有物理特性。
2. 增加 material_info 参数,用于在初始化时直接绑定 Unilab UUID。
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
category: str = "plate",
ordered_items: collections.OrderedDict = None,
ordering: Optional[collections.OrderedDict] = None,
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs):
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "plate",
ordered_items: collections.OrderedDict = None,
ordering: Optional[collections.OrderedDict] = None,
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs,
):
# 如果 ordered_items 不为 None直接使用
if ordered_items is not None:
items = ordered_items
@@ -142,40 +173,34 @@ class PRCXI9300Plate(Plate):
else:
items = None
ordering_param = None
# 根据情况传递不同的参数
if items is not None:
super().__init__(name, size_x, size_y, size_z,
ordered_items=items,
category=category,
model=model, **kwargs)
super().__init__(
name, size_x, size_y, size_z, ordered_items=items, category=category, model=model, **kwargs
)
elif ordering_param is not None:
# 传递 ordering 参数,让 Plate 自己创建 Well 对象
super().__init__(name, size_x, size_y, size_z,
ordering=ordering_param,
category=category,
model=model, **kwargs)
super().__init__(
name, size_x, size_y, size_z, ordering=ordering_param, category=category, model=model, **kwargs
)
else:
super().__init__(name, size_x, size_y, size_z,
category=category,
model=model, **kwargs)
super().__init__(name, size_x, size_y, size_z, category=category, model=model, **kwargs)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
def load_state(self, state: Dict[str, Any]) -> None:
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
try:
data = super().serialize_state()
except AttributeError:
data = {}
if hasattr(self, '_unilabos_state') and self._unilabos_state:
if hasattr(self, "_unilabos_state") and self._unilabos_state:
safe_state = {}
for k, v in self._unilabos_state.items():
# 如果是 Material 字典,深入检查
@@ -188,23 +213,32 @@ class PRCXI9300Plate(Plate):
else:
# 打印日志提醒(可选)
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
pass
pass
safe_state[k] = safe_material
# 其他顶层属性也进行类型检查
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
safe_state[k] = v
data.update(safe_state)
return data # 其他顶层属性也进行类型检查
return data # 其他顶层属性也进行类型检查
class PRCXI9300TipRack(TipRack):
""" 专用吸头盒类 """
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
category: str = "tip_rack",
ordered_items: collections.OrderedDict = None,
ordering: Optional[collections.OrderedDict] = None,
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs):
"""专用吸头盒类"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "tip_rack",
ordered_items: collections.OrderedDict = None,
ordering: Optional[collections.OrderedDict] = None,
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs,
):
# 如果 ordered_items 不为 None直接使用
if ordered_items is not None:
items = ordered_items
@@ -225,27 +259,23 @@ class PRCXI9300TipRack(TipRack):
else:
items = None
ordering_param = None
# 根据情况传递不同的参数
if items is not None:
super().__init__(name, size_x, size_y, size_z,
ordered_items=items,
category=category,
model=model, **kwargs)
super().__init__(
name, size_x, size_y, size_z, ordered_items=items, category=category, model=model, **kwargs
)
elif ordering_param is not None:
# 传递 ordering 参数,让 TipRack 自己创建 Tip 对象
super().__init__(name, size_x, size_y, size_z,
ordering=ordering_param,
category=category,
model=model, **kwargs)
super().__init__(
name, size_x, size_y, size_z, ordering=ordering_param, category=category, model=model, **kwargs
)
else:
super().__init__(name, size_x, size_y, size_z,
category=category,
model=model, **kwargs)
super().__init__(name, size_x, size_y, size_z, category=category, model=model, **kwargs)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
def load_state(self, state: Dict[str, Any]) -> None:
super().load_state(state)
self._unilabos_state = state
@@ -255,7 +285,7 @@ class PRCXI9300TipRack(TipRack):
data = super().serialize_state()
except AttributeError:
data = {}
if hasattr(self, '_unilabos_state') and self._unilabos_state:
if hasattr(self, "_unilabos_state") and self._unilabos_state:
safe_state = {}
for k, v in self._unilabos_state.items():
# 如果是 Material 字典,深入检查
@@ -268,26 +298,33 @@ class PRCXI9300TipRack(TipRack):
else:
# 打印日志提醒(可选)
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
pass
pass
safe_state[k] = safe_material
# 其他顶层属性也进行类型检查
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
safe_state[k] = v
data.update(safe_state)
return data
class PRCXI9300Trash(Trash):
"""PRCXI 9300 的专用 Trash 类,继承自 Trash。
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
category: str = "trash",
material_info: Optional[Dict[str, Any]] = None,
**kwargs):
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "trash",
material_info: Optional[Dict[str, Any]] = None,
**kwargs,
):
if name != "trash":
print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.")
super().__init__(name, size_x, size_y, size_z, **kwargs)
@@ -306,7 +343,7 @@ class PRCXI9300Trash(Trash):
data = super().serialize_state()
except AttributeError:
data = {}
if hasattr(self, '_unilabos_state') and self._unilabos_state:
if hasattr(self, "_unilabos_state") and self._unilabos_state:
safe_state = {}
for k, v in self._unilabos_state.items():
# 如果是 Material 字典,深入检查
@@ -319,29 +356,37 @@ class PRCXI9300Trash(Trash):
else:
# 打印日志提醒(可选)
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
pass
pass
safe_state[k] = safe_material
# 其他顶层属性也进行类型检查
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
safe_state[k] = v
data.update(safe_state)
return data
class PRCXI9300TubeRack(TubeRack):
"""
专用管架类:用于 EP 管架、试管架等。
继承自 PLR 的 TubeRack并支持注入 material_info (UUID)。
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
category: str = "tube_rack",
items: Optional[Dict[str, Any]] = None,
ordered_items: Optional[OrderedDict] = None,
ordering: Optional[OrderedDict] = None,
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs):
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "tube_rack",
items: Optional[Dict[str, Any]] = None,
ordered_items: Optional[OrderedDict] = None,
ordering: Optional[OrderedDict] = None,
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs,
):
# 如果 ordered_items 不为 None直接使用
if ordered_items is not None:
items_to_pass = ordered_items
@@ -367,24 +412,16 @@ class PRCXI9300TubeRack(TubeRack):
else:
items_to_pass = None
ordering_param = None
# 根据情况传递不同的参数
if items_to_pass is not None:
super().__init__(name, size_x, size_y, size_z,
ordered_items=items_to_pass,
model=model,
**kwargs)
super().__init__(name, size_x, size_y, size_z, ordered_items=items_to_pass, model=model, **kwargs)
elif ordering_param is not None:
# 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象
super().__init__(name, size_x, size_y, size_z,
ordering=ordering_param,
model=model,
**kwargs)
super().__init__(name, size_x, size_y, size_z, ordering=ordering_param, model=model, **kwargs)
else:
super().__init__(name, size_x, size_y, size_z,
model=model,
**kwargs)
super().__init__(name, size_x, size_y, size_z, model=model, **kwargs)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
@@ -394,7 +431,7 @@ class PRCXI9300TubeRack(TubeRack):
data = super().serialize_state()
except AttributeError:
data = {}
if hasattr(self, '_unilabos_state') and self._unilabos_state:
if hasattr(self, "_unilabos_state") and self._unilabos_state:
safe_state = {}
for k, v in self._unilabos_state.items():
# 如果是 Material 字典,深入检查
@@ -407,33 +444,41 @@ class PRCXI9300TubeRack(TubeRack):
else:
# 打印日志提醒(可选)
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
pass
pass
safe_state[k] = safe_material
# 其他顶层属性也进行类型检查
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
safe_state[k] = v
data.update(safe_state)
return data
class PRCXI9300PlateAdapter(PlateAdapter):
"""
专用板式适配器类:用于承载 Plate 的底座(如 PCR 适配器、磁吸架等)。
支持注入 material_info (UUID)。
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
category: str = "plate_adapter",
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
# 参数给予默认值 (标准96孔板尺寸)
adapter_hole_size_x: float = 127.76,
adapter_hole_size_y: float = 85.48,
adapter_hole_size_z: float = 10.0, # 假设凹槽深度或板子放置高度
dx: Optional[float] = None,
dy: Optional[float] = None,
dz: float = 0.0, # 默认Z轴偏移
**kwargs):
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "plate_adapter",
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
# 参数给予默认值 (标准96孔板尺寸)
adapter_hole_size_x: float = 127.76,
adapter_hole_size_y: float = 85.48,
adapter_hole_size_z: float = 10.0, # 假设凹槽深度或板子放置高度
dx: Optional[float] = None,
dy: Optional[float] = None,
dz: float = 0.0, # 默认Z轴偏移
**kwargs,
):
# 自动居中计算:如果未指定 dx/dy则根据适配器尺寸和孔尺寸计算居中位置
if dx is None:
dx = (size_x - adapter_hole_size_x) / 2
@@ -441,20 +486,20 @@ class PRCXI9300PlateAdapter(PlateAdapter):
dy = (size_y - adapter_hole_size_y) / 2
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
dx=dx,
dy=dy,
dz=dz,
adapter_hole_size_x=adapter_hole_size_x,
adapter_hole_size_y=adapter_hole_size_y,
adapter_hole_size_z=adapter_hole_size_z,
model=model,
**kwargs
model=model,
**kwargs,
)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
@@ -464,7 +509,7 @@ class PRCXI9300PlateAdapter(PlateAdapter):
data = super().serialize_state()
except AttributeError:
data = {}
if hasattr(self, '_unilabos_state') and self._unilabos_state:
if hasattr(self, "_unilabos_state") and self._unilabos_state:
safe_state = {}
for k, v in self._unilabos_state.items():
# 如果是 Material 字典,深入检查
@@ -477,15 +522,16 @@ class PRCXI9300PlateAdapter(PlateAdapter):
else:
# 打印日志提醒(可选)
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
pass
pass
safe_state[k] = safe_material
# 其他顶层属性也进行类型检查
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
safe_state[k] = v
data.update(safe_state)
return data
class PRCXI9300Handler(LiquidHandlerAbstract):
support_touch_tip = False
@@ -518,7 +564,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
if "Material" in child.children[0]._unilabos_state:
number = int(child.name.replace("T", ""))
tablets_info.append(
WorkTablets(Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"])
WorkTablets(
Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]
)
)
if is_9320:
print("当前设备是9320")
@@ -538,9 +586,14 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
super().post_init(ros_node)
self._unilabos_backend.post_init(ros_node)
def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn:
def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SetLiquidReturn:
return super().set_liquid(wells, liquid_names, volumes)
def set_liquid_from_plate(
self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
) -> SetLiquidFromPlateReturn:
return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes)
def set_group(self, group_name: str, wells: List[Well], volumes: List[float]):
return super().set_group(group_name, wells, volumes)
@@ -799,7 +852,8 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
return await self._unilabos_backend.shaker_action(time, module_no, amplitude, is_wait)
async def heater_action(self, temperature: float, time: int):
return await self._unilabos_backend.heater_action(temperature, time)
return await self._unilabos_backend.heater_action(temperature, time)
async def move_plate(
self,
plate: Plate,
@@ -822,10 +876,11 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
drop_direction,
pickup_direction,
pickup_distance_from_top,
target_plate_number = to,
target_plate_number=to,
**backend_kwargs,
)
class PRCXI9300Backend(LiquidHandlerBackend):
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。
@@ -878,31 +933,28 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self.steps_todo_list.append(step)
return step
async def pick_up_resource(self, pickup: ResourcePickup, **backend_kwargs):
resource=pickup.resource
offset=pickup.offset
pickup_distance_from_top=pickup.pickup_distance_from_top
direction=pickup.direction
resource = pickup.resource
offset = pickup.offset
pickup_distance_from_top = pickup.pickup_distance_from_top
direction = pickup.direction
plate_number = int(resource.parent.name.replace("T", ""))
is_whole_plate = True
balance_height = 0
step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height)
self.steps_todo_list.append(step)
return step
async def drop_resource(self, drop: ResourceDrop, **backend_kwargs):
plate_number = None
target_plate_number = backend_kwargs.get("target_plate_number", None)
if target_plate_number is not None:
plate_number = int(target_plate_number.name.replace("T", ""))
is_whole_plate = True
balance_height = 0
if plate_number is None:
@@ -911,7 +963,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self.steps_todo_list.append(step)
return step
async def heater_action(self, temperature: float, time: int):
print(f"\n\nHeater action: temperature={temperature}, time={time}\n\n")
# return await self.api_client.heater_action(temperature, time)
@@ -968,7 +1019,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
error_code = self.api_client.get_error_code()
if error_code:
print(f"PRCXI9300 error code detected: {error_code}")
# 清除错误代码
self.api_client.clear_error_code()
print("PRCXI9300 error code cleared.")
@@ -976,11 +1027,11 @@ class PRCXI9300Backend(LiquidHandlerBackend):
# 执行重置
print("Starting PRCXI9300 reset...")
self.api_client.call("IAutomation", "Reset")
# 检查重置状态并等待完成
while not self.is_reset_ok:
print("Waiting for PRCXI9300 to reset...")
if hasattr(self, '_ros_node') and self._ros_node is not None:
if hasattr(self, "_ros_node") and self._ros_node is not None:
await self._ros_node.sleep(1)
else:
await asyncio.sleep(1)
@@ -998,7 +1049,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
"""Pick up tips from the specified resource."""
# INSERT_YOUR_CODE
# Ensure use_channels is converted to a list of ints if it's an array
if hasattr(use_channels, 'tolist'):
if hasattr(use_channels, "tolist"):
_use_channels = use_channels.tolist()
else:
_use_channels = list(use_channels) if use_channels is not None else None
@@ -1052,7 +1103,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
async def drop_tips(self, ops: List[Drop], use_channels: List[int] = None):
"""Pick up tips from the specified resource."""
if hasattr(use_channels, 'tolist'):
if hasattr(use_channels, "tolist"):
_use_channels = use_channels.tolist()
else:
_use_channels = list(use_channels) if use_channels is not None else None
@@ -1135,7 +1186,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
none_keys: List[str] = [],
):
"""Mix liquid in the specified resources."""
plate_indexes = []
for op in targets:
deck = op.parent.parent.parent
@@ -1178,7 +1229,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None):
"""Aspirate liquid from the specified resources."""
if hasattr(use_channels, 'tolist'):
if hasattr(use_channels, "tolist"):
_use_channels = use_channels.tolist()
else:
_use_channels = list(use_channels) if use_channels is not None else None
@@ -1235,7 +1286,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int] = None):
"""Dispense liquid into the specified resources."""
if hasattr(use_channels, 'tolist'):
if hasattr(use_channels, "tolist"):
_use_channels = use_channels.tolist()
else:
_use_channels = list(use_channels) if use_channels is not None else None
@@ -1416,7 +1467,6 @@ class PRCXI9300Api:
time.sleep(1)
return success
def call(self, service: str, method: str, params: Optional[list] = None) -> Any:
payload = json.dumps(
{"ServiceName": service, "MethodName": method, "Paramters": params or []}, separators=(",", ":")
@@ -1543,7 +1593,7 @@ class PRCXI9300Api:
assist_fun5: str = "",
liquid_method: str = "NormalDispense",
axis: str = "Left",
) -> Dict[str, Any]:
) -> Dict[str, Any]:
return {
"StepAxis": axis,
"Function": "Imbibing",
@@ -1621,7 +1671,7 @@ class PRCXI9300Api:
assist_fun5: str = "",
liquid_method: str = "NormalDispense",
axis: str = "Left",
) -> Dict[str, Any]:
) -> Dict[str, Any]:
return {
"StepAxis": axis,
"Function": "Blending",
@@ -1681,11 +1731,11 @@ class PRCXI9300Api:
"LiquidDispensingMethod": liquid_method,
}
def clamp_jaw_pick_up(self,
def clamp_jaw_pick_up(
self,
plate_no: int,
is_whole_plate: bool,
balance_height: int,
) -> Dict[str, Any]:
return {
"StepAxis": "ClampingJaw",
@@ -1695,7 +1745,7 @@ class PRCXI9300Api:
"HoleRow": 1,
"HoleCol": 1,
"BalanceHeight": balance_height,
"PlateOrHoleNum": f"T{plate_no}"
"PlateOrHoleNum": f"T{plate_no}",
}
def clamp_jaw_drop(
@@ -1703,7 +1753,6 @@ class PRCXI9300Api:
plate_no: int,
is_whole_plate: bool,
balance_height: int,
) -> Dict[str, Any]:
return {
"StepAxis": "ClampingJaw",
@@ -1713,7 +1762,7 @@ class PRCXI9300Api:
"HoleRow": 1,
"HoleCol": 1,
"BalanceHeight": balance_height,
"PlateOrHoleNum": f"T{plate_no}"
"PlateOrHoleNum": f"T{plate_no}",
}
def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
@@ -1726,6 +1775,7 @@ class PRCXI9300Api:
"AssistFun4": is_wait,
}
class DefaultLayout:
def __init__(self, product_name: str = "PRCXI9300"):
@@ -2104,7 +2154,9 @@ if __name__ == "__main__":
size_y=50,
size_z=10,
category="tip_rack",
ordered_items=collections.OrderedDict({k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()}),
ordered_items=collections.OrderedDict(
{k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()}
),
)
tip_rack_serialized = tip_rack.serialize()
tip_rack_serialized["parent_name"] = deck.name
@@ -2299,43 +2351,37 @@ if __name__ == "__main__":
A = tree_to_list([resource_plr_to_ulab(deck)])
with open("deck.json", "w", encoding="utf-8") as f:
A.insert(0, {
"id": "PRCXI",
"name": "PRCXI",
"parent": None,
"type": "device",
"class": "liquid_handler.prcxi",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"deck": {
"_resource_child_name": "PRCXI_Deck",
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck"
A.insert(
0,
{
"id": "PRCXI",
"name": "PRCXI",
"parent": None,
"type": "device",
"class": "liquid_handler.prcxi",
"position": {"x": 0, "y": 0, "z": 0},
"config": {
"deck": {
"_resource_child_name": "PRCXI_Deck",
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
},
"host": "192.168.0.121",
"port": 9999,
"timeout": 10.0,
"axis": "Right",
"channel_num": 1,
"setup": False,
"debug": True,
"simulator": True,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"is_9320": True,
},
"host": "192.168.0.121",
"port": 9999,
"timeout": 10.0,
"axis": "Right",
"channel_num": 1,
"setup": False,
"debug": True,
"simulator": True,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"is_9320": True
"data": {},
"children": ["PRCXI_Deck"],
},
"data": {},
"children": [
"PRCXI_Deck"
]
})
)
A[1]["parent"] = "PRCXI"
json.dump({
"nodes": A,
"links": []
}, f, indent=4, ensure_ascii=False)
json.dump({"nodes": A, "links": []}, f, indent=4, ensure_ascii=False)
handler = PRCXI9300Handler(
deck=deck,
@@ -2377,7 +2423,6 @@ if __name__ == "__main__":
time.sleep(5)
os._exit(0)
prcxi_api = PRCXI9300Api(host="192.168.0.121", port=9999)
prcxi_api.list_matrices()
prcxi_api.get_all_materials()

View File

@@ -0,0 +1,376 @@
# -*- coding: utf-8 -*-
"""
ZDT X42 Closed-Loop Stepper Motor Driver
RS485 Serial Communication via USB-Serial Converter
- Baudrate: 115200
"""
import serial
import time
import threading
import struct
import logging
from typing import Optional, Any
try:
from unilabos.device_comms.universal_driver import UniversalDriver
except ImportError:
class UniversalDriver:
def __init__(self, *args, **kwargs):
self.logger = logging.getLogger(self.__class__.__name__)
def execute_command_from_outer(self, command: Any): pass
from serial.rs485 import RS485Settings
class ZDTX42Driver(UniversalDriver):
"""
ZDT X42 闭环步进电机驱动器
支持功能:
- 速度模式运行
- 位置模式运行 (相对/绝对)
- 位置读取和清零
- 使能/禁用控制
通信协议:
- 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B]
- 响应长度根据功能码决定
"""
def __init__(
self,
port: str,
baudrate: int = 115200,
device_id: int = 1,
timeout: float = 0.5,
debug: bool = False
):
"""
初始化 ZDT X42 电机驱动
Args:
port: 串口设备路径
baudrate: 波特率 (默认 115200)
device_id: 设备地址 (1-255)
timeout: 通信超时时间(秒)
debug: 是否启用调试输出
"""
super().__init__()
self.id = device_id
self.debug = debug
self.lock = threading.RLock()
self.status = "idle" # 对应注册表中的 status (str)
self.position = 0 # 对应注册表中的 position (int)
try:
self.ser = serial.Serial(
port=port,
baudrate=baudrate,
timeout=timeout,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE
)
# 启用 RS485 模式
try:
self.ser.rs485_mode = RS485Settings(
rts_level_for_tx=True,
rts_level_for_rx=False
)
except Exception:
pass # RS485 模式是可选的
self.logger.info(
f"ZDT X42 Motor connected: {port} "
f"(Baud: {baudrate}, ID: {device_id})"
)
# 自动使能电机,确保初始状态可运动
self.enable(True)
# 启动背景轮询线程,确保 position 实时刷新
self._stop_event = threading.Event()
self._polling_thread = threading.Thread(
target=self._update_loop,
name=f"ZDTPolling_{port}",
daemon=True
)
self._polling_thread.start()
except Exception as e:
self.logger.error(f"Failed to open serial port {port}: {e}")
self.ser = None
def _update_loop(self):
"""背景循环读取电机位置"""
while not self._stop_event.is_set():
try:
self.get_position()
except Exception as e:
if self.debug:
self.logger.error(f"Polling error: {e}")
time.sleep(1.0) # 每1秒刷新一次位置数据
def _send(self, func_code: int, payload: list) -> bytes:
"""
发送指令并接收响应
Args:
func_code: 功能码
payload: 数据负载 (list of bytes)
Returns:
响应数据 (bytes)
"""
if not self.ser:
self.logger.error("Serial port not available")
return b""
with self.lock:
# 清空输入缓冲区
self.ser.reset_input_buffer()
# 构建消息: [ID] [功能码] [数据...] [校验位=0x6B]
message = bytes([self.id, func_code] + payload + [0x6B])
# 发送
self.ser.write(message)
# 根据功能码决定响应长度
# 查询类指令返回 10 字节,控制类指令返回 4 字节
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
response = self.ser.read(read_len)
# 调试输出
if self.debug:
sent_hex = message.hex().upper()
recv_hex = response.hex().upper() if response else 'TIMEOUT'
print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}")
return response
def enable(self, on: bool = True) -> bool:
"""
使能/禁用电机
Args:
on: True=使能(锁轴), False=禁用(松轴)
Returns:
是否成功
"""
state = 1 if on else 0
resp = self._send(0xF3, [0xAB, state, 0])
return len(resp) >= 4
def move_speed(
self,
speed_rpm: int,
direction: str = "CW",
acceleration: int = 10
) -> bool:
"""
速度模式运行
Args:
speed_rpm: 转速 (RPM)
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
acceleration: 加速度 (0-255)
Returns:
是否成功
"""
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
speed_bytes = struct.pack('>H', int(speed_rpm))
self.status = f"moving@{speed_rpm}rpm"
resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0])
return len(resp) >= 4
def move_position(
self,
pulses: int,
speed_rpm: int,
direction: str = "CW",
acceleration: int = 10,
absolute: bool = False
) -> bool:
"""
位置模式运行
Args:
pulses: 脉冲数
speed_rpm: 转速 (RPM)
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
acceleration: 加速度 (0-255)
absolute: True=绝对位置, False=相对位置
Returns:
是否成功
"""
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
speed_bytes = struct.pack('>H', int(speed_rpm))
self.status = f"moving_to_{pulses}"
pulse_bytes = struct.pack('>I', int(pulses))
abs_flag = 1 if absolute else 0
payload = [
dir_val,
speed_bytes[0], speed_bytes[1],
acceleration,
pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3],
abs_flag,
0
]
resp = self._send(0xFD, payload)
return len(resp) >= 4
def stop(self) -> bool:
"""
停止电机
Returns:
是否成功
"""
self.status = "idle"
resp = self._send(0xFE, [0x98, 0])
return len(resp) >= 4
def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool:
"""
电机旋转 1/4 圈 (阻塞式)
假设电机细分为 3200 脉冲/圈1/4 圈 = 800 脉冲
"""
pulses = 800
success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False)
if success:
# 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60))
# 1/4 rev / (RPM/60) = 15.0 / RPM
estimated_time = 15.0 / max(1, speed_rpm)
time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲
self.status = "idle"
return success
def wait_time(self, duration_s: float) -> bool:
"""
等待指定时间 (秒)
"""
self.logger.info(f"Waiting for {duration_s} seconds...")
time.sleep(duration_s)
return True
def set_zero(self) -> bool:
"""
清零当前位置
Returns:
是否成功
"""
resp = self._send(0x0A, [])
return len(resp) >= 4
def get_position(self) -> Optional[int]:
"""
读取当前位置 (脉冲数)
Returns:
当前位置脉冲数,失败返回 None
"""
resp = self._send(0x32, [])
if len(resp) >= 8:
# 响应格式: [ID] [Func] [符号位] [数值4字节] [校验]
sign = resp[2] # 0=正, 1=负
value = struct.unpack('>I', resp[3:7])[0]
self.position = -value if sign == 1 else value
if self.debug:
print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}")
return self.position
self.logger.warning("Failed to read position")
return None
def close(self):
"""关闭串口连接并停止线程"""
if hasattr(self, '_stop_event'):
self._stop_event.set()
if self.ser and self.ser.is_open:
self.ser.close()
self.logger.info("Serial port closed")
# ============================================================
# 测试和调试代码
# ============================================================
def test_motor():
"""基础功能测试"""
logging.basicConfig(level=logging.INFO)
print("="*60)
print("ZDT X42 电机驱动测试")
print("="*60)
driver = ZDTX42Driver(
port="/dev/tty.usbserial-3110",
baudrate=115200,
device_id=2,
debug=True
)
if not driver.ser:
print("❌ 串口打开失败")
return
try:
# 测试 1: 读取位置
print("\n[1] 读取当前位置")
pos = driver.get_position()
print(f"✓ 当前位置: {pos} 脉冲")
# 测试 2: 使能
print("\n[2] 使能电机")
driver.enable(True)
time.sleep(0.3)
print("✓ 电机已锁定")
# 测试 3: 相对位置运动
print("\n[3] 相对位置运动 (1000脉冲)")
driver.move_position(pulses=1000, speed_rpm=60, direction="CW")
time.sleep(2)
pos = driver.get_position()
print(f"✓ 新位置: {pos}")
# 测试 4: 速度运动
print("\n[4] 速度模式 (30RPM, 3秒)")
driver.move_speed(speed_rpm=30, direction="CW")
time.sleep(3)
driver.stop()
pos = driver.get_position()
print(f"✓ 停止后位置: {pos}")
# 测试 5: 禁用
print("\n[5] 禁用电机")
driver.enable(False)
print("✓ 电机已松开")
print("\n" + "="*60)
print("✅ 测试完成")
print("="*60)
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
finally:
driver.close()
if __name__ == "__main__":
test_motor()

View File

@@ -623,6 +623,119 @@ class ChinweDevice(UniversalDriver):
time.sleep(duration)
return True
def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700,
max_cycles: int = 0, timeout: int = 300) -> bool:
"""
分液步骤 - 液位传感器与电机联动
当液位传感器检测到"有液"时,电机顺时针旋转指定脉冲数
当液位传感器检测到"无液"时,电机逆时针旋转指定脉冲数
:param motor_id: 电机ID (必须在初始化时配置的motor_ids中)
:param speed: 电机转速 (RPM)
:param pulses: 每次旋转的脉冲数 (默认700约为1/4圈,假设3200脉冲/圈)
:param max_cycles: 最大执行循环次数 (0=无限制,默认0)
:param timeout: 整体超时时间 (秒)
:return: 成功返回True,超时或失败返回False
"""
motor_id = int(motor_id)
speed = int(speed)
pulses = int(pulses)
max_cycles = int(max_cycles)
timeout = int(timeout)
# 检查电机是否存在
if motor_id not in self.motors:
self.logger.error(f"Motor {motor_id} not found in configured motors: {list(self.motors.keys())}")
return False
# 检查传感器是否可用
if not self.sensor:
self.logger.error("Sensor not initialized")
return False
motor = self.motors[motor_id]
# 停止轮询线程,避免与 separation_step 同时读取传感器造成串口冲突
self.logger.info("Stopping polling thread for separation_step...")
self._stop_event.set()
if self._poll_thread and self._poll_thread.is_alive():
self._poll_thread.join(timeout=2.0)
# 使能电机
self.logger.info(f"Enabling motor {motor_id}...")
motor.enable(True)
time.sleep(0.2)
self.logger.info(f"Starting separation step: motor_id={motor_id}, speed={speed} RPM, "
f"pulses={pulses}, max_cycles={max_cycles}, timeout={timeout}s")
# 记录上一次的液位状态
last_level = None
cycle_count = 0
start_time = time.time()
error_count = 0
try:
while True:
# 检查超时
if time.time() - start_time > timeout:
self.logger.warning(f"Separation step timeout after {timeout} seconds")
return False
# 检查循环次数限制
if max_cycles > 0 and cycle_count >= max_cycles:
self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}")
return True
# 读取传感器数据
data = self.sensor.read_level()
if data is None:
error_count += 1
if error_count > 5:
self.logger.warning("Sensor read failed multiple times, retrying...")
error_count = 0
time.sleep(0.5)
continue
error_count = 0
current_level = data['level']
rssi = data['rssi']
# 检测状态变化 (包括首次检测)
if current_level != last_level:
cycle_count += 1
if current_level:
# 有液 -> 电机顺时针旋转
self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), "
f"rotating motor {motor_id} clockwise {pulses} pulses")
motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False)
# 等待电机完成 (预估时间)
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
else:
# 无液 -> 电机逆时针旋转
self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), "
f"rotating motor {motor_id} counter-clockwise {pulses} pulses")
motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False)
# 等待电机完成 (预估时间)
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
# 更新状态
last_level = current_level
# 轮询间隔
time.sleep(0.1)
finally:
# 恢复轮询线程
self.logger.info("Restarting polling thread...")
self._start_polling()
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
"""支持标准 JSON 指令调用"""
return super().execute_command_from_outer(command_dict)

View File

@@ -0,0 +1,379 @@
# -*- coding: utf-8 -*-
"""
XKC RS485 液位传感器 (Modbus RTU)
说明:
1. 遵循 Modbus-RTU 协议。
2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。
3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。
4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。
"""
import struct
import threading
import time
import logging
import serial
from typing import Optional, Dict, Any, List
from unilabos.device_comms.universal_driver import UniversalDriver
class TransportManager:
"""
统一通信管理类。
仅支持 串口 (Serial/有线) 连接。
"""
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.logger = logger
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
self.serial = None
self._connect_serial()
def _connect_serial(self):
try:
self.serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
except Exception as e:
raise ConnectionError(f"Serial open failed: {e}")
def close(self):
"""关闭连接"""
if self.serial and self.serial.is_open:
self.serial.close()
def clear_buffer(self):
"""清空缓冲区 (Thread-safe)"""
with self.lock:
if self.serial:
self.serial.reset_input_buffer()
def write(self, data: bytes):
"""发送原始字节"""
with self.lock:
if self.serial:
self.serial.write(data)
def read(self, size: int) -> bytes:
"""读取指定长度字节"""
if self.serial:
return self.serial.read(size)
return b''
class XKCSensorDriver(UniversalDriver):
"""XKC RS485 液位传感器 (Modbus RTU)"""
def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6,
threshold: int = 300, timeout: float = 3.0, debug: bool = False):
super().__init__()
self.port = port
self.baudrate = baudrate
self.device_id = device_id
self.threshold = threshold
self.timeout = timeout
self.debug = debug
self.level = False
self.rssi = 0
self.status = {"level": self.level, "rssi": self.rssi}
try:
self.transport = TransportManager(port, baudrate, timeout, logger=self.logger)
self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})")
except Exception as e:
self.logger.error(f"Failed to connect XKCSensorDriver: {e}")
self.transport = None
# 启动背景轮询线程,确保 status 实时刷新
self._stop_event = threading.Event()
self._polling_thread = threading.Thread(
target=self._update_loop,
name=f"XKCPolling_{port}",
daemon=True
)
if self.transport:
self._polling_thread.start()
def _update_loop(self):
"""背景循环读取传感器数据"""
while not self._stop_event.is_set():
try:
self.read_level()
except Exception as e:
if self.debug:
self.logger.error(f"Polling error: {e}")
time.sleep(2.0) # 每2秒刷新一次数据
def _crc(self, data: bytes) -> bytes:
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
else: crc >>= 1
return struct.pack('<H', crc)
def read_level(self) -> Optional[Dict[str, Any]]:
"""
读取液位。
返回: {'level': bool, 'rssi': int}
"""
if not self.transport:
return None
with self.transport.lock:
self.transport.clear_buffer()
# Modbus Read Registers: 01 03 00 01 00 02 CRC
payload = struct.pack('>HH', 0x0001, 0x0002)
msg = struct.pack('BB', self.device_id, 0x03) + payload
msg += self._crc(msg)
if self.debug:
self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}")
self.transport.write(msg)
# Read header
h = self.transport.read(3) # Addr, Func, Len
if self.debug:
self.logger.info(f"RX Header: {h.hex().upper()}")
if len(h) < 3: return None
length = h[2]
# Read body + CRC
body = self.transport.read(length + 2)
if self.debug:
self.logger.info(f"RX Body+CRC: {body.hex().upper()}")
if len(body) < length + 2:
# Firmware bug fix specific to some modules
if len(body) == 4 and length == 4:
pass
else:
return None
data = body[:-2]
# 根据手册说明:
# 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液)
# 寄存器 0x0002 (data[2:4]): 信号强度 RSSI
hw_level = False
rssi = 0
if len(data) >= 4:
hw_level = ((data[0] << 8) | data[1]) == 1
rssi = (data[2] << 8) | data[3]
elif len(data) == 2:
# 兼容模式: 某些老固件可能只返回 1 个寄存器
rssi = (data[0] << 8) | data[1]
hw_level = rssi > self.threshold
else:
return None
# 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验
# 注意: 如果用户显式设置了 THRESHOLD我们可以在逻辑中做权衡
self.level = hw_level or (rssi > self.threshold)
self.rssi = rssi
result = {
'level': self.level,
'rssi': self.rssi
}
self.status = result
return result
def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool:
"""
等待液位达到目标状态 (阻塞式)
"""
self.logger.info(f"Waiting for level: {target_state}")
start_time = time.time()
while (time.time() - start_time) < timeout:
res = self.read_level()
if res and res.get('level') == target_state:
return True
time.sleep(0.5)
self.logger.warning(f"Wait level timeout ({timeout}s)")
return False
def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool:
"""
实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。
一旦检测到符合目标状态,立即返回。
Args:
target_state: True 为“有液”, False 为“无液”
timeout: 最大等待时间(秒)
"""
state_str = "有液" if target_state else "无液"
self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)")
start_time = time.time()
while (time.time() - start_time) < timeout:
res = self.read_level() # 内部已更新 self.level 和 self.rssi
if res:
current_level = res.get('level')
current_rssi = res.get('rssi')
if current_level == target_state:
self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})")
return True
if self.debug:
self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}")
time.sleep(0.2) # 高频采样
self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)")
return False
def set_threshold(self, threshold: int):
"""设置液位判定阈值"""
self.threshold = int(threshold)
self.logger.info(f"Threshold updated to: {self.threshold}")
def change_device_id(self, new_id: int) -> bool:
"""
修改设备的 Modbus 从站地址。
寄存器: 0x0004, 功能码: 0x06
"""
if not (1 <= new_id <= 254):
self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.")
return False
self.logger.info(f"Changing device ID from {self.device_id} to {new_id}")
success = self._write_single_register(0x0004, new_id)
if success:
self.device_id = new_id # 更新内存中的地址
self.logger.info(f"Device ID update command sent successfully (target {new_id}).")
return success
def change_baudrate(self, baud_code: int) -> bool:
"""
更改通讯波特率 (寄存器: 0x0005)。
设置成功后传感器 LED 会闪烁,通常无数据返回。
波特率代码对照表 (16进制):
05: 2400
06: 4800
07: 9600 (默认)
08: 14400
09: 19200
0A: 28800
0C: 57600
0D: 115200
0E: 128000
0F: 256000
"""
self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})")
# 写入寄存器 0x0005
self._write_single_register(0x0005, baud_code)
self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.")
return True
def factory_reset(self) -> bool:
"""
恢复出厂设置 (通过广播地址 FF)。
设置地址为 01逻辑为向 0x0004 写入 0x0002
"""
self.logger.info("Sending factory reset command via broadcast address FF...")
# 广播指令通常无回显
self._write_single_register(0x0004, 0x0002, slave_id=0xFF)
self.logger.info("Factory reset command sent. Device address should be 01 now.")
return True
def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool:
"""内部辅助函数: Modbus 功能码 06 写单个寄存器"""
if not self.transport: return False
target_id = slave_id if slave_id is not None else self.device_id
msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value)
msg += self._crc(msg)
with self.transport.lock:
self.transport.clear_buffer()
if self.debug:
self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}")
self.transport.write(msg)
# 广播地址、波特率修改或厂家特定指令可能无回显
if target_id == 0xFF or reg_addr == 0x0005:
time.sleep(0.5)
return True
# 等待返回 (正常应返回相同报文)
resp = self.transport.read(len(msg))
if self.debug:
self.logger.info(f"RX Write Response: {resp.hex().upper()}")
return resp == msg
def close(self):
if self.transport:
self.transport.close()
if __name__ == "__main__":
# 快速实例化测试
import logging
# 减少冗余日志,仅显示重要信息
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
# 硬件配置 (根据实际情况修改)
TEST_PORT = "/dev/tty.usbserial-3110"
SLAVE_ID = 1
THRESHOLD = 300
print("\n" + "="*50)
print(f" XKC RS485 传感器独立测试程序")
print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}")
print("="*50)
sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False)
try:
if sensor.transport:
print(f"\n开始实时连续采样测试 (持续 15 秒)...")
print(f"按 Ctrl+C 可提前停止\n")
start_time = time.time()
duration = 15
count = 0
while time.time() - start_time < duration:
count += 1
res = sensor.read_level()
if res:
rssi = res['rssi']
level = res['level']
status_str = "【有液】" if level else "【无液】"
# 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史)
# 为了方便查看变化,我们直接打印
elapsed = time.time() - start_time
print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}")
else:
print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)")
time.sleep(0.5) # 每秒采样 2 次
print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---")
# [3] 测试动态修改阈值
print(f"\n[3] 动态修改阈值演示...")
new_threshold = 400
sensor.set_threshold(new_threshold)
res = sensor.read_level()
if res:
print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}")
sensor.set_threshold(THRESHOLD) # 还原
except KeyboardInterrupt:
print("\n[!] 用户中断测试")
except Exception as e:
print(f"\n[!] 测试运行出错: {e}")
finally:
sensor.close()
print("\n--- 测试程序已退出 ---\n")

View File

@@ -258,7 +258,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.info(f"[同步→Bioyond] 物料不存在于 Bioyond将创建新物料并入库")
# 第1步从配置中获取仓库配置
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
# 确定目标仓库名称
parent_name = None

View File

View File

View File

@@ -317,6 +317,47 @@ separator.chinwe:
- port
type: object
type: UniLabJsonCommand
separation_step:
goal:
max_cycles: 0
motor_id: 5
pulses: 700
speed: 60
timeout: 300
handles: {}
schema:
description: 分液步骤 - 液位传感器与电机联动 (有液→顺时针, 无液→逆时针)
properties:
goal:
properties:
max_cycles:
default: 0
description: 最大循环次数 (0=无限制)
type: integer
motor_id:
default: '5'
description: 选择电机
enum:
- '4'
- '5'
title: '注: 4=搅拌, 5=旋钮'
type: string
pulses:
default: 700
description: 每次旋转脉冲数 (约1/4圈)
type: integer
speed:
default: 60
description: 电机转速 (RPM)
type: integer
timeout:
default: 300
description: 超时时间 (秒)
type: integer
required:
- motor_id
type: object
type: UniLabJsonCommand
wait_sensor_level:
goal:
target_state: 有液

View File

@@ -9284,7 +9284,13 @@ liquid_handler.prcxi:
data_source: handle
data_type: resource
handler_key: input_wells
label: InputWells
label: 待设定液体孔
output:
- data_key: wells.@flatten
data_source: executor
data_type: resource
handler_key: output_wells
label: 已设定液体孔
placeholder_keys:
wells: unilabos_resources
result: {}
@@ -9400,6 +9406,163 @@ liquid_handler.prcxi:
title: LiquidHandlerSetLiquid
type: object
type: LiquidHandlerSetLiquid
set_liquid_from_plate:
feedback: {}
goal: {}
goal_default:
liquid_names: null
plate: null
volumes: null
well_names: null
handles:
input:
- data_key: plate
data_source: handle
data_type: resource
handler_key: input_plate
label: 待设定液体板
output:
- data_key: plate.@flatten
data_source: executor
data_type: resource
handler_key: output_plate
label: 已设定液体板
- data_key: wells.@flatten
data_source: executor
data_type: resource
handler_key: output_wells
label: 已设定液体孔
- data_key: volumes
data_source: executor
data_type: number_array
handler_key: output_volumes
label: 各孔设定体积
placeholder_keys:
plate: unilabos_resources
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
liquid_names:
items:
type: string
type: array
plate:
properties:
category:
type: string
children:
items:
type: string
type: array
config:
type: string
data:
type: string
id:
type: string
name:
type: string
parent:
type: string
pose:
properties:
orientation:
properties:
w:
type: number
x:
type: number
y:
type: number
z:
type: number
required:
- x
- y
- z
- w
title: orientation
type: object
position:
properties:
x:
type: number
y:
type: number
z:
type: number
required:
- x
- y
- z
title: position
type: object
required:
- position
- orientation
title: pose
type: object
sample_id:
type: string
type:
type: string
required:
- id
- name
- sample_id
- children
- parent
- type
- category
- pose
- config
- data
title: plate
type: object
volumes:
items:
type: number
type: array
well_names:
items:
type: string
type: array
required:
- plate
- well_names
- liquid_names
- volumes
type: object
result:
properties:
plate:
items: {}
title: Plate
type: array
volumes:
items:
type: number
title: Volumes
type: array
wells:
items: {}
title: Wells
type: array
required:
- plate
- wells
- volumes
title: SetLiquidFromPlateReturn
type: object
required:
- goal
title: set_liquid_from_plate参数
type: object
type: UniLabJsonCommand
set_tiprack:
feedback: {}
goal:
@@ -9745,21 +9908,21 @@ liquid_handler.prcxi:
- 0
handles:
input:
- data_key: liquid
- data_key: sources
data_source: handle
data_type: resource
handler_key: sources
label: sources
- data_key: liquid
data_source: executor
handler_key: sources_identifier
label: 待移动液体
- data_key: targets
data_source: handle
data_type: resource
handler_key: targets
label: targets
- data_key: liquid
data_source: executor
handler_key: targets_identifier
label: 转移目标
- data_key: tip_rack
data_source: handle
data_type: resource
handler_key: tip_rack
label: tip_rack
handler_key: tip_rack_identifier
label: 墙头盒
output:
- data_key: liquid
data_source: handle

View File

@@ -0,0 +1,286 @@
motor.zdt_x42:
category:
- motor
class:
action_value_mappings:
auto-enable:
feedback: {}
goal: {}
goal_default:
'on': true
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 使能或禁用电机。使能后电机进入锁轴状态,可接收运动指令;禁用后电机进入松轴状态。
properties:
feedback: {}
goal:
properties:
'on':
default: true
type: boolean
required: []
type: object
result: {}
required:
- goal
title: enable参数
type: object
type: UniLabJsonCommand
auto-get_position:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 获取当前电机脉冲位置。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
properties:
position:
type: integer
type: object
required:
- goal
title: get_position参数
type: object
type: UniLabJsonCommand
auto-move_position:
feedback: {}
goal: {}
goal_default:
absolute: false
acceleration: 10
direction: CW
pulses: 1000
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 位置模式运行。控制电机移动到指定脉冲位置或相对于当前位置移动指定脉冲数。
properties:
feedback: {}
goal:
properties:
absolute:
default: false
type: boolean
acceleration:
default: 10
maximum: 255
minimum: 0
type: integer
direction:
default: CW
enum:
- CW
- CCW
type: string
pulses:
default: 1000
type: integer
speed_rpm:
default: 60
minimum: 0
type: integer
required:
- pulses
- speed_rpm
type: object
result: {}
required:
- goal
title: move_position参数
type: object
type: UniLabJsonCommand
auto-move_speed:
feedback: {}
goal: {}
goal_default:
acceleration: 10
direction: CW
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 速度模式运行。控制电机以指定转速和方向持续转动。
properties:
feedback: {}
goal:
properties:
acceleration:
default: 10
maximum: 255
minimum: 0
type: integer
direction:
default: CW
enum:
- CW
- CCW
type: string
speed_rpm:
default: 60
minimum: 0
type: integer
required:
- speed_rpm
type: object
result: {}
required:
- goal
title: move_speed参数
type: object
type: UniLabJsonCommand
auto-rotate_quarter:
feedback: {}
goal: {}
goal_default:
direction: CW
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 电机旋转 1/4 圈 (阻塞式)。
properties:
feedback: {}
goal:
properties:
direction:
default: CW
enum:
- CW
- CCW
type: string
speed_rpm:
default: 60
minimum: 1
type: integer
required: []
type: object
result: {}
required:
- goal
title: rotate_quarter参数
type: object
type: UniLabJsonCommand
auto-set_zero:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 将当前电机位置设为零点。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: set_zero参数
type: object
type: UniLabJsonCommand
auto-stop:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 立即停止电机运动。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: stop参数
type: object
type: UniLabJsonCommand
auto-wait_time:
feedback: {}
goal: {}
goal_default:
duration_s: 1.0
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 等待指定时间 (秒)。
properties:
feedback: {}
goal:
properties:
duration_s:
default: 1.0
minimum: 0
type: number
required:
- duration_s
type: object
result: {}
required:
- goal
title: wait_time参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.motor.ZDT_X42:ZDTX42Driver
status_types:
position: int
status: str
type: python
config_info: []
description: ZDT X42 闭环步进电机驱动。支持速度运行、精确位置控制、位置查询和清零功能。适用于各种需要精确运动控制的实验室自动化场景。
handles: []
icon: ''
init_param_schema:
config:
properties:
baudrate:
default: 115200
type: integer
debug:
default: false
type: boolean
device_id:
default: 1
type: integer
port:
type: string
timeout:
default: 0.5
type: number
required:
- port
type: object
data:
properties:
position:
type: integer
status:
type: string
required:
- status
- position
type: object
version: 1.0.0

View File

@@ -0,0 +1,148 @@
sensor.xkc_rs485:
category:
- sensor
- separator
class:
action_value_mappings:
auto-change_baudrate:
goal:
baud_code: 7
handles: {}
schema:
description: '更改通讯波特率 (设置成功后无返回,且需手动切换波特率重连)。代码表 (16进制): 05=2400, 06=4800,
07=9600, 08=14400, 09=19200, 0A=28800, 0C=57600, 0D=115200, 0E=128000,
0F=256000'
properties:
goal:
properties:
baud_code:
description: '波特率代码 (例如: 7 为 9600, 13 即 0x0D 为 115200)'
type: integer
required:
- baud_code
type: object
type: UniLabJsonCommand
auto-change_device_id:
goal:
new_id: 1
handles: {}
schema:
description: 修改传感器的 Modbus 从站地址
properties:
goal:
properties:
new_id:
description: 新的从站地址 (1-254)
maximum: 254
minimum: 1
type: integer
required:
- new_id
type: object
type: UniLabJsonCommand
auto-factory_reset:
goal: {}
handles: {}
schema:
description: 恢复出厂设置 (地址重置为 01)
properties:
goal:
type: object
type: UniLabJsonCommand
auto-read_level:
goal: {}
handles: {}
schema:
description: 直接读取当前液位及信号强度
properties:
goal:
type: object
type: object
type: UniLabJsonCommand
auto-set_threshold:
goal:
threshold: 300
handles: {}
schema:
description: 设置液位判定阈值
properties:
goal:
properties:
threshold:
type: integer
required:
- threshold
type: object
type: UniLabJsonCommand
auto-wait_for_liquid:
goal:
target_state: true
timeout: 120
handles: {}
schema:
description: 实时检测电导率(RSSI)并等待用户指定的状态
properties:
goal:
properties:
target_state:
default: true
description: 目标状态 (True=有液, False=无液)
type: boolean
timeout:
default: 120
description: 超时时间 (秒)
required:
- target_state
type: object
type: UniLabJsonCommand
auto-wait_level:
goal:
level: true
timeout: 10
handles: {}
schema:
description: 等待液位达到目标状态
properties:
goal:
properties:
level:
type: boolean
timeout:
type: number
required:
- level
type: object
type: UniLabJsonCommand
module: unilabos.devices.separator.xkc_sensor:XKCSensorDriver
status_types:
level: bool
rssi: int
type: python
config_info: []
description: XKC RS485 非接触式液位传感器 (Modbus RTU)
handles: []
icon: ''
init_param_schema:
config:
properties:
baudrate:
default: 9600
type: integer
debug:
default: false
type: boolean
device_id:
default: 1
type: integer
port:
type: string
threshold:
default: 300
type: integer
timeout:
default: 3.0
type: number
required:
- port
type: object
version: 1.0.0

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ import os
import sys
import inspect
import importlib
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List, Union, Tuple
@@ -60,6 +62,7 @@ class Registry:
self.device_module_to_registry = {}
self.resource_type_registry = {}
self._setup_called = False # 跟踪setup是否已调用
self._registry_lock = threading.Lock() # 多线程加载时的锁
# 其他状态变量
# self.is_host_mode = False # 移至BasicConfig中
@@ -163,6 +166,7 @@ class Registry:
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
"class_name": "unilabos_class",
},
},
"test_latency": {
@@ -176,8 +180,7 @@ class Registry:
"result": {},
"schema": test_latency_schema,
"goal_default": {
arg["name"]: arg["default"]
for arg in test_latency_method_info.get("args", [])
arg["name"]: arg["default"] for arg in test_latency_method_info.get("args", [])
},
"handles": {},
},
@@ -261,67 +264,115 @@ class Registry:
# 标记setup已被调用
self._setup_called = True
def _load_single_resource_file(
self, file: Path, complete_registry: bool, upload_registry: bool
) -> Tuple[Dict[str, Any], Dict[str, Any], bool]:
"""
加载单个资源文件 (线程安全)
Returns:
(data, complete_data, is_valid): 资源数据, 完整数据, 是否有效
"""
try:
with open(file, encoding="utf-8", mode="r") as f:
data = yaml.safe_load(io.StringIO(f.read()))
except Exception as e:
logger.warning(f"[UniLab Registry] 读取资源文件失败: {file}, 错误: {e}")
return {}, {}, False
if not data:
return {}, {}, False
complete_data = {}
for resource_id, resource_info in data.items():
if "version" not in resource_info:
resource_info["version"] = "1.0.0"
if "category" not in resource_info:
resource_info["category"] = [file.stem]
elif file.stem not in resource_info["category"]:
resource_info["category"].append(file.stem)
elif not isinstance(resource_info.get("category"), list):
resource_info["category"] = [resource_info["category"]]
if "config_info" not in resource_info:
resource_info["config_info"] = []
if "icon" not in resource_info:
resource_info["icon"] = ""
if "handles" not in resource_info:
resource_info["handles"] = []
if "init_param_schema" not in resource_info:
resource_info["init_param_schema"] = {}
if "config_info" in resource_info:
del resource_info["config_info"]
if "file_path" in resource_info:
del resource_info["file_path"]
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
if upload_registry:
class_info = resource_info.get("class", {})
if len(class_info) and "module" in class_info:
if class_info.get("type") == "pylabrobot":
res_class = get_class(class_info["module"])
if callable(res_class) and not isinstance(res_class, type):
res_instance = res_class(res_class.__name__)
res_ulr = tree_to_list([resource_plr_to_ulab(res_instance)])
resource_info["config_info"] = res_ulr
resource_info["registry_type"] = "resource"
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
complete_data = dict(sorted(complete_data.items()))
complete_data = copy.deepcopy(complete_data)
if complete_registry:
try:
with open(file, "w", encoding="utf-8") as f:
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
except Exception as e:
logger.warning(f"[UniLab Registry] 写入资源文件失败: {file}, 错误: {e}")
return data, complete_data, True
def load_resource_types(self, path: os.PathLike, complete_registry: bool, upload_registry: bool):
abs_path = Path(path).absolute()
resource_path = abs_path / "resources"
files = list(resource_path.glob("*/*.yaml"))
logger.trace(f"[UniLab Registry] load resources? {resource_path.exists()}, total: {len(files)}")
current_resource_number = len(self.resource_type_registry) + 1
for i, file in enumerate(files):
with open(file, encoding="utf-8", mode="r") as f:
data = yaml.safe_load(io.StringIO(f.read()))
complete_data = {}
if data:
# 为每个资源添加文件路径信息
for resource_id, resource_info in data.items():
if "version" not in resource_info:
resource_info["version"] = "1.0.0"
if "category" not in resource_info:
resource_info["category"] = [file.stem]
elif file.stem not in resource_info["category"]:
resource_info["category"].append(file.stem)
elif not isinstance(resource_info.get("category"), list):
resource_info["category"] = [resource_info["category"]]
if "config_info" not in resource_info:
resource_info["config_info"] = []
if "icon" not in resource_info:
resource_info["icon"] = ""
if "handles" not in resource_info:
resource_info["handles"] = []
if "init_param_schema" not in resource_info:
resource_info["init_param_schema"] = {}
if "config_info" in resource_info:
del resource_info["config_info"]
if "file_path" in resource_info:
del resource_info["file_path"]
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
if upload_registry:
class_info = resource_info.get("class", {})
if len(class_info) and "module" in class_info:
if class_info.get("type") == "pylabrobot":
res_class = get_class(class_info["module"])
if callable(res_class) and not isinstance(
res_class, type
): # 有的是类,有的是函数,这里暂时只登记函数类的
res_instance = res_class(res_class.__name__)
res_ulr = tree_to_list([resource_plr_to_ulab(res_instance)])
resource_info["config_info"] = res_ulr
resource_info["registry_type"] = "resource"
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
complete_data = dict(sorted(complete_data.items()))
complete_data = copy.deepcopy(complete_data)
if complete_registry:
with open(file, "w", encoding="utf-8") as f:
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
logger.debug(f"[UniLab Registry] resources: {resource_path.exists()}, total: {len(files)}")
if not files:
return
# 使用线程池并行加载
max_workers = min(8, len(files))
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_file = {
executor.submit(self._load_single_resource_file, file, complete_registry, upload_registry): file
for file in files
}
for future in as_completed(future_to_file):
file = future_to_file[future]
try:
data, complete_data, is_valid = future.result()
if is_valid:
results.append((file, data))
except Exception as e:
logger.warning(f"[UniLab Registry] 处理资源文件异常: {file}, 错误: {e}")
# 线程安全地更新注册表
current_resource_number = len(self.resource_type_registry) + 1
with self._registry_lock:
for i, (file, data) in enumerate(results):
self.resource_type_registry.update(data)
logger.trace( # type: ignore
f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} "
logger.trace(
f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(results)} "
+ f"Add {list(data.keys())}"
)
current_resource_number += 1
else:
logger.debug(f"[UniLab Registry] Res File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}")
# 记录无效文件
valid_files = {r[0] for r in results}
for file in files:
if file not in valid_files:
logger.debug(f"[UniLab Registry] Res File Not Valid YAML File: {file.absolute()}")
def _extract_class_docstrings(self, module_string: str) -> Dict[str, str]:
"""
@@ -673,213 +724,244 @@ class Registry:
"handles": {},
}
def _load_single_device_file(
self, file: Path, complete_registry: bool, get_yaml_from_goal_type
) -> Tuple[Dict[str, Any], Dict[str, Any], bool, List[str]]:
"""
加载单个设备文件 (线程安全)
Returns:
(data, complete_data, is_valid, device_ids): 设备数据, 完整数据, 是否有效, 设备ID列表
"""
try:
with open(file, encoding="utf-8", mode="r") as f:
data = yaml.safe_load(io.StringIO(f.read()))
except Exception as e:
logger.warning(f"[UniLab Registry] 读取设备文件失败: {file}, 错误: {e}")
return {}, {}, False, []
if not data:
return {}, {}, False, []
complete_data = {}
action_str_type_mapping = {
"UniLabJsonCommand": "UniLabJsonCommand",
"UniLabJsonCommandAsync": "UniLabJsonCommandAsync",
}
status_str_type_mapping = {}
device_ids = []
for device_id, device_config in data.items():
if "version" not in device_config:
device_config["version"] = "1.0.0"
if "category" not in device_config:
device_config["category"] = [file.stem]
elif file.stem not in device_config["category"]:
device_config["category"].append(file.stem)
if "config_info" not in device_config:
device_config["config_info"] = []
if "description" not in device_config:
device_config["description"] = ""
if "icon" not in device_config:
device_config["icon"] = ""
if "handles" not in device_config:
device_config["handles"] = []
if "init_param_schema" not in device_config:
device_config["init_param_schema"] = {}
if "class" in device_config:
if "status_types" not in device_config["class"] or device_config["class"]["status_types"] is None:
device_config["class"]["status_types"] = {}
if (
"action_value_mappings" not in device_config["class"]
or device_config["class"]["action_value_mappings"] is None
):
device_config["class"]["action_value_mappings"] = {}
enhanced_info = {}
if complete_registry:
device_config["class"]["status_types"].clear()
enhanced_info = get_enhanced_class_info(device_config["class"]["module"], use_dynamic=True)
if not enhanced_info.get("dynamic_import_success", False):
continue
device_config["class"]["status_types"].update(
{k: v["return_type"] for k, v in enhanced_info["status_methods"].items()}
)
for status_name, status_type in device_config["class"]["status_types"].items():
if isinstance(status_type, tuple) or status_type in ["Any", "None", "Unknown"]:
status_type = "String"
device_config["class"]["status_types"][status_name] = status_type
try:
target_type = self._replace_type_with_class(status_type, device_id, f"状态 {status_name}")
except ROSMsgNotFound:
continue
if target_type in [dict, list]:
target_type = String
status_str_type_mapping[status_type] = target_type
device_config["class"]["status_types"] = dict(sorted(device_config["class"]["status_types"].items()))
if complete_registry:
old_action_configs = {}
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
old_action_configs[action_name] = action_config
device_config["class"]["action_value_mappings"] = {
k: v
for k, v in device_config["class"]["action_value_mappings"].items()
if not k.startswith("auto-")
}
device_config["class"]["action_value_mappings"].update(
{
f"auto-{k}": {
"type": "UniLabJsonCommandAsync" if v["is_async"] else "UniLabJsonCommand",
"goal": {},
"feedback": {},
"result": {},
"schema": self._generate_unilab_json_command_schema(
v["args"],
k,
v.get("return_annotation"),
old_action_configs.get(f"auto-{k}", {}).get("schema"),
),
"goal_default": {i["name"]: i["default"] for i in v["args"]},
"handles": old_action_configs.get(f"auto-{k}", {}).get("handles", []),
"placeholder_keys": {
i["name"]: (
"unilabos_resources"
if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot"
or i["type"] == ("list", "unilabos.registry.placeholder_type:ResourceSlot")
else "unilabos_devices"
)
for i in v["args"]
if i.get("type", "")
in [
"unilabos.registry.placeholder_type:ResourceSlot",
"unilabos.registry.placeholder_type:DeviceSlot",
("list", "unilabos.registry.placeholder_type:ResourceSlot"),
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
]
},
}
for k, v in enhanced_info["action_methods"].items()
if k not in device_config["class"]["action_value_mappings"]
}
)
for action_name, old_config in old_action_configs.items():
if action_name in device_config["class"]["action_value_mappings"]:
old_schema = old_config.get("schema", {})
if "description" in old_schema and old_schema["description"]:
device_config["class"]["action_value_mappings"][action_name]["schema"][
"description"
] = old_schema["description"]
device_config["init_param_schema"] = {}
device_config["init_param_schema"]["config"] = self._generate_unilab_json_command_schema(
enhanced_info["init_params"], "__init__"
)["properties"]["goal"]
device_config["init_param_schema"]["data"] = self._generate_status_types_schema(
enhanced_info["status_methods"]
)
device_config.pop("schema", None)
device_config["class"]["action_value_mappings"] = dict(
sorted(device_config["class"]["action_value_mappings"].items())
)
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
if "handles" not in action_config:
action_config["handles"] = {}
elif isinstance(action_config["handles"], list):
if len(action_config["handles"]):
logger.error(f"设备{device_id} {action_name} 的handles配置错误应该是字典类型")
continue
else:
action_config["handles"] = {}
if "type" in action_config:
action_type_str: str = action_config["type"]
if not action_type_str.startswith("UniLabJsonCommand"):
try:
target_type = self._replace_type_with_class(
action_type_str, device_id, f"动作 {action_name}"
)
except ROSMsgNotFound:
continue
action_str_type_mapping[action_type_str] = target_type
if target_type is not None:
action_config["goal_default"] = yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(target_type.Goal))
)
action_config["schema"] = ros_action_to_json_schema(target_type)
else:
logger.warning(
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
)
complete_data[device_id] = copy.deepcopy(dict(sorted(device_config.items())))
for status_name, status_type in device_config["class"]["status_types"].items():
device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type]
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
if action_config["type"] not in action_str_type_mapping:
continue
action_config["type"] = action_str_type_mapping[action_config["type"]]
self._add_builtin_actions(device_config, device_id)
device_config["file_path"] = str(file.absolute()).replace("\\", "/")
device_config["registry_type"] = "device"
device_ids.append(device_id)
complete_data = dict(sorted(complete_data.items()))
complete_data = copy.deepcopy(complete_data)
try:
with open(file, "w", encoding="utf-8") as f:
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
except Exception as e:
logger.warning(f"[UniLab Registry] 写入设备文件失败: {file}, 错误: {e}")
return data, complete_data, True, device_ids
def load_device_types(self, path: os.PathLike, complete_registry: bool):
# return
abs_path = Path(path).absolute()
devices_path = abs_path / "devices"
device_comms_path = abs_path / "device_comms"
files = list(devices_path.glob("*.yaml")) + list(device_comms_path.glob("*.yaml"))
logger.trace( # type: ignore
logger.trace(
f"[UniLab Registry] devices: {devices_path.exists()}, device_comms: {device_comms_path.exists()}, "
+ f"total: {len(files)}"
)
current_device_number = len(self.device_type_registry) + 1
if not files:
return
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
for i, file in enumerate(files):
with open(file, encoding="utf-8", mode="r") as f:
data = yaml.safe_load(io.StringIO(f.read()))
complete_data = {}
action_str_type_mapping = {
"UniLabJsonCommand": "UniLabJsonCommand",
"UniLabJsonCommandAsync": "UniLabJsonCommandAsync",
# 使用线程池并行加载
max_workers = min(8, len(files))
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_file = {
executor.submit(self._load_single_device_file, file, complete_registry, get_yaml_from_goal_type): file
for file in files
}
status_str_type_mapping = {}
if data:
# 在添加到注册表前处理类型替换
for device_id, device_config in data.items():
# 添加文件路径信息 - 使用规范化的完整文件路径
if "version" not in device_config:
device_config["version"] = "1.0.0"
if "category" not in device_config:
device_config["category"] = [file.stem]
elif file.stem not in device_config["category"]:
device_config["category"].append(file.stem)
if "config_info" not in device_config:
device_config["config_info"] = []
if "description" not in device_config:
device_config["description"] = ""
if "icon" not in device_config:
device_config["icon"] = ""
if "handles" not in device_config:
device_config["handles"] = []
if "init_param_schema" not in device_config:
device_config["init_param_schema"] = {}
if "class" in device_config:
if (
"status_types" not in device_config["class"]
or device_config["class"]["status_types"] is None
):
device_config["class"]["status_types"] = {}
if (
"action_value_mappings" not in device_config["class"]
or device_config["class"]["action_value_mappings"] is None
):
device_config["class"]["action_value_mappings"] = {}
enhanced_info = {}
if complete_registry:
device_config["class"]["status_types"].clear()
enhanced_info = get_enhanced_class_info(device_config["class"]["module"], use_dynamic=True)
if not enhanced_info.get("dynamic_import_success", False):
continue
device_config["class"]["status_types"].update(
{k: v["return_type"] for k, v in enhanced_info["status_methods"].items()}
)
for status_name, status_type in device_config["class"]["status_types"].items():
if isinstance(status_type, tuple) or status_type in ["Any", "None", "Unknown"]:
status_type = "String" # 替换成ROS的String便于显示
device_config["class"]["status_types"][status_name] = status_type
try:
target_type = self._replace_type_with_class(
status_type, device_id, f"状态 {status_name}"
)
except ROSMsgNotFound:
continue
if target_type in [
dict,
list,
]: # 对于嵌套类型返回的对象,暂时处理成字符串,无法直接进行转换
target_type = String
status_str_type_mapping[status_type] = target_type
device_config["class"]["status_types"] = dict(
sorted(device_config["class"]["status_types"].items())
)
if complete_registry:
# 保存原有的 action 配置(用于保留 schema 的 description 和 handles 等)
old_action_configs = {}
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
old_action_configs[action_name] = action_config
for future in as_completed(future_to_file):
file = future_to_file[future]
try:
data, complete_data, is_valid, device_ids = future.result()
if is_valid:
results.append((file, data, device_ids))
except Exception as e:
logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}")
device_config["class"]["action_value_mappings"] = {
k: v
for k, v in device_config["class"]["action_value_mappings"].items()
if not k.startswith("auto-")
}
# 处理动作值映射
device_config["class"]["action_value_mappings"].update(
{
f"auto-{k}": {
"type": "UniLabJsonCommandAsync" if v["is_async"] else "UniLabJsonCommand",
"goal": {},
"feedback": {},
"result": {},
"schema": self._generate_unilab_json_command_schema(
v["args"],
k,
v.get("return_annotation"),
# 传入旧的 schema 以保留字段 description
old_action_configs.get(f"auto-{k}", {}).get("schema"),
),
"goal_default": {i["name"]: i["default"] for i in v["args"]},
# 保留原有的 handles 配置
"handles": old_action_configs.get(f"auto-{k}", {}).get("handles", []),
"placeholder_keys": {
i["name"]: (
"unilabos_resources"
if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot"
or i["type"]
== ("list", "unilabos.registry.placeholder_type:ResourceSlot")
else "unilabos_devices"
)
for i in v["args"]
if i.get("type", "")
in [
"unilabos.registry.placeholder_type:ResourceSlot",
"unilabos.registry.placeholder_type:DeviceSlot",
("list", "unilabos.registry.placeholder_type:ResourceSlot"),
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
]
},
}
# 不生成已配置action的动作
for k, v in enhanced_info["action_methods"].items()
if k not in device_config["class"]["action_value_mappings"]
}
)
# 恢复原有的 description 信息(非 auto- 开头的动作)
for action_name, old_config in old_action_configs.items():
if action_name in device_config["class"]["action_value_mappings"]: # 有一些会被删除
old_schema = old_config.get("schema", {})
if "description" in old_schema and old_schema["description"]:
device_config["class"]["action_value_mappings"][action_name]["schema"][
"description"
] = old_schema["description"]
device_config["init_param_schema"] = {}
device_config["init_param_schema"]["config"] = self._generate_unilab_json_command_schema(
enhanced_info["init_params"], "__init__"
)["properties"]["goal"]
device_config["init_param_schema"]["data"] = self._generate_status_types_schema(
enhanced_info["status_methods"]
)
device_config.pop("schema", None)
device_config["class"]["action_value_mappings"] = dict(
sorted(device_config["class"]["action_value_mappings"].items())
)
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
if "handles" not in action_config:
action_config["handles"] = {}
elif isinstance(action_config["handles"], list):
if len(action_config["handles"]):
logger.error(f"设备{device_id} {action_name} 的handles配置错误应该是字典类型")
continue
else:
action_config["handles"] = {}
if "type" in action_config:
action_type_str: str = action_config["type"]
# 通过Json发放指令而不是通过特殊的ros action进行处理
if not action_type_str.startswith("UniLabJsonCommand"):
try:
target_type = self._replace_type_with_class(
action_type_str, device_id, f"动作 {action_name}"
)
except ROSMsgNotFound:
continue
action_str_type_mapping[action_type_str] = target_type
if target_type is not None:
action_config["goal_default"] = yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(target_type.Goal))
)
action_config["schema"] = ros_action_to_json_schema(target_type)
else:
logger.warning(
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
)
complete_data[device_id] = copy.deepcopy(dict(sorted(device_config.items()))) # 稍后dump到文件
for status_name, status_type in device_config["class"]["status_types"].items():
device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type]
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
if action_config["type"] not in action_str_type_mapping:
continue
action_config["type"] = action_str_type_mapping[action_config["type"]]
# 添加内置的驱动命令动作
self._add_builtin_actions(device_config, device_id)
device_config["file_path"] = str(file.absolute()).replace("\\", "/")
device_config["registry_type"] = "device"
logger.trace( # type: ignore
f"[UniLab Registry] Device-{current_device_number} File-{i+1}/{len(files)} Add {device_id} "
# 线程安全地更新注册表
current_device_number = len(self.device_type_registry) + 1
with self._registry_lock:
for file, data, device_ids in results:
self.device_type_registry.update(data)
for device_id in device_ids:
logger.trace(
f"[UniLab Registry] Device-{current_device_number} Add {device_id} "
+ f"[{data[device_id].get('name', '未命名设备')}]"
)
current_device_number += 1
complete_data = dict(sorted(complete_data.items()))
complete_data = copy.deepcopy(complete_data)
with open(file, "w", encoding="utf-8") as f:
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
self.device_type_registry.update(data)
else:
logger.debug(
f"[UniLab Registry] Device File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}"
)
# 记录无效文件
valid_files = {r[0] for r in results}
for file in files:
if file not in valid_files:
logger.debug(f"[UniLab Registry] Device File Not Valid YAML File: {file.absolute()}")
def obtain_registry_device_info(self):
devices = []

View File

@@ -46,3 +46,16 @@ BIOYOND_PolymerStation_8StockCarrier:
init_param_schema: {}
registry_type: resource
version: 1.0.0
BIOYOND_PolymerStation_TipBox:
category:
- bottle_carriers
- tip_racks
class:
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_TipBox
type: pylabrobot
description: BIOYOND_PolymerStation_TipBox (4x6布局24个枪头孔位)
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -82,14 +82,3 @@ BIOYOND_PolymerStation_Solution_Beaker:
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_TipBox:
category:
- bottles
- tip_boxes
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_TipBox
type: pylabrobot
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0

View File

@@ -1,4 +1,4 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d, Container
from unilabos.resources.itemized_carrier import BottleCarrier
from unilabos.resources.bioyond.bottles import (
@@ -9,6 +9,28 @@ from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Reagent_Bottle,
BIOYOND_PolymerStation_Flask,
)
def BIOYOND_PolymerStation_Tip(name: str, size_x: float = 8.0, size_y: float = 8.0, size_z: float = 50.0) -> Container:
"""创建单个枪头资源
Args:
name: 枪头名称
size_x: 枪头宽度 (mm)
size_y: 枪头长度 (mm)
size_z: 枪头高度 (mm)
Returns:
Container: 枪头容器
"""
return Container(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category="tip",
model="BIOYOND_PolymerStation_Tip",
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask,小瓶-Vial
@@ -322,3 +344,88 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1")
return carrier
def BIOYOND_PolymerStation_TipBox(
name: str,
size_x: float = 127.76, # 枪头盒宽度
size_y: float = 85.48, # 枪头盒长度
size_z: float = 100.0, # 枪头盒高度
barcode: str = None,
) -> BottleCarrier:
"""创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
Args:
name: 枪头盒名称
size_x: 枪头盒宽度 (mm)
size_y: 枪头盒长度 (mm)
size_z: 枪头盒高度 (mm)
barcode: 条形码
Returns:
BottleCarrier: 包含24个枪头孔位的枪头盒载架
布局说明:
- 4行×6列 (A-D, 1-6)
- 枪头孔位间距: 18mm (x方向) × 18mm (y方向)
- 起始位置居中对齐
- 索引顺序: 列优先 (0=A1, 1=B1, 2=C1, 3=D1, 4=A2, ...)
"""
# 枪头孔位参数
num_cols = 6 # 1-6 (x方向)
num_rows = 4 # A-D (y方向)
tip_diameter = 8.0 # 枪头孔位直径
tip_spacing_x = 18.0 # 列间距 (增加到18mm更宽松)
tip_spacing_y = 18.0 # 行间距 (增加到18mm更宽松)
# 计算起始位置 (居中对齐)
total_width = (num_cols - 1) * tip_spacing_x + tip_diameter
total_height = (num_rows - 1) * tip_spacing_y + tip_diameter
start_x = (size_x - total_width) / 2
start_y = (size_y - total_height) / 2
# 使用 create_ordered_items_2d 创建孔位
# create_ordered_items_2d 返回的 key 是数字索引: 0, 1, 2, ...
# 顺序是列优先: 先y后x (即 0=A1, 1=B1, 2=C1, 3=D1, 4=A2, 5=B2, ...)
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=num_cols,
num_items_y=num_rows,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=tip_spacing_x,
item_dy=tip_spacing_y,
size_x=tip_diameter,
size_y=tip_diameter,
size_z=50.0, # 枪头深度
)
# 更新 sites 中每个 ResourceHolder 的名称
for k, v in sites.items():
v.name = f"{name}_{v.name}"
# 创建枪头盒载架
# 注意:不设置 category使用默认的 "bottle_carrier",这样前端会显示为完整的矩形载架
tip_box = BottleCarrier(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
sites=sites, # 直接使用数字索引的 sites
model="BIOYOND_PolymerStation_TipBox",
)
# 设置自定义属性
tip_box.barcode = barcode
tip_box.tip_count = 24 # 4行×6列
tip_box.num_items_x = num_cols
tip_box.num_items_y = num_rows
tip_box.num_items_z = 1
# ⭐ 枪头盒不需要放入子资源
# 与其他 carrier 不同,枪头盒在 Bioyond 中是一个整体
# 不需要追踪每个枪头的状态,保持为空的 ResourceHolder 即可
# 这样前端会显示24个空槽位可以用于放置枪头
return tip_box

View File

@@ -116,7 +116,9 @@ def BIOYOND_PolymerStation_TipBox(
size_z: float = 100.0, # 枪头盒高度
barcode: str = None,
):
"""创建4×6枪头盒 (24个枪头)
"""创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
注意:此函数已弃用,请使用 bottle_carriers.py 中的版本
Args:
name: 枪头盒名称
@@ -126,55 +128,11 @@ def BIOYOND_PolymerStation_TipBox(
barcode: 条形码
Returns:
TipBoxCarrier: 包含24个枪头孔位的枪头盒
BottleCarrier: 包含24个枪头孔位的枪头盒载架
"""
from pylabrobot.resources import Container, Coordinate
# 创建枪头盒容器
tip_box = Container(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category="tip_rack",
model="BIOYOND_PolymerStation_TipBox_4x6",
)
# 设置自定义属性
tip_box.barcode = barcode
tip_box.tip_count = 24 # 4行×6列
tip_box.num_items_x = 6 # 6列
tip_box.num_items_y = 4 # 4行
# 创建24个枪头孔位 (4行×6列)
# 假设孔位间距为 9mm
tip_spacing_x = 9.0 # 列间距
tip_spacing_y = 9.0 # 行间距
start_x = 14.38 # 第一个孔位的x偏移
start_y = 11.24 # 第一个孔位的y偏移
for row in range(4): # A, B, C, D
for col in range(6): # 1-6
spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6
x = start_x + col * tip_spacing_x
y = start_y + row * tip_spacing_y
# 创建枪头孔位容器
tip_spot = Container(
name=spot_name,
size_x=8.0, # 单个枪头孔位大小
size_y=8.0,
size_z=size_z - 10.0, # 略低于盒子高度
category="tip_spot",
)
# 添加到枪头盒
tip_box.assign_child_resource(
tip_spot,
location=Coordinate(x=x, y=y, z=0)
)
return tip_box
# 重定向到 bottle_carriers.py 中的实现
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_PolymerStation_TipBox as TipBox_Carrier
return TipBox_Carrier(name=name, size_x=size_x, size_y=size_y, size_z=size_z, barcode=barcode)
def BIOYOND_PolymerStation_Flask(

View File

@@ -260,7 +260,7 @@ def read_node_link_json(
resource_tree_set = canonicalize_nodes_data(nodes)
# 标准化边数据
links = data.get("links", [])
links = data.get("links", data.get("edges", []))
standardized_links = canonicalize_links_ports(links, resource_tree_set)
# 构建 NetworkX 图(需要转换回 dict 格式)
@@ -597,6 +597,8 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w
"tube": "tube",
"bottle_carrier": "bottle_carrier",
"plate_adapter": "plate_adapter",
"electrode_sheet": "electrode_sheet",
"material_hole": "material_hole",
}
if source in replace_info:
return replace_info[source]
@@ -757,9 +759,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
bottle = plr_material[number] = initialize_resource(
{"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR
)
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
# 只有具有 tracker 属性的容器才设置液体信息(如 Bottle, Well
# ResourceHolder 等不支持液体追踪的容器跳过
if hasattr(bottle, "tracker"):
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
bottle.code = detail.get("code", "")
logger.debug(f" └─ [子物料] {detail['name']}{plr_material.name}[{number}] (类型:{typeName})")
else:
@@ -768,9 +773,11 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
# 只对有 capacity 属性的容器(液体容器)处理液体追踪
if hasattr(plr_material, 'capacity'):
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
# 确保 bottletracker 属性才设置液体信息
if hasattr(bottle, "tracker"):
bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
plr_materials.append(plr_material)
@@ -799,24 +806,29 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
wh_name = loc.get("whName")
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
# 必须在warehouse映射之前先获取坐标以便后续调整
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右"
# 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧
# 根据列号(y)判断: 1-4映射到左侧, 5-8映射到右侧
if wh_name == "堆栈1":
x_val = loc.get("x", 1)
if 1 <= x_val <= 4:
if 1 <= y <= 4:
wh_name = "堆栈1左"
elif 5 <= x_val <= 8:
elif 5 <= y <= 8:
wh_name = "堆栈1右"
y = y - 4 # 调整列号: 5-8映射到1-4
else:
logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围无法映射到堆栈1左或堆栈1右")
logger.warning(f"物料 {material['name']} 的列号 y={y} 超出范围无法映射到堆栈1左或堆栈1右")
continue
# 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射
if wh_name == "站内Tip盒堆栈":
y_val = loc.get("y", 1)
if y_val == 1:
if y == 1:
wh_name = "站内Tip盒堆栈(右)"
elif y_val in [2, 3]:
elif y in [2, 3]:
wh_name = "站内Tip盒堆栈(左)"
y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列
@@ -824,15 +836,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
warehouse = deck.warehouses[wh_name]
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4
# 特殊处理竖向warehouse站内试剂存放堆栈、测量小瓶仓库
# 这些warehouse使用 vertical-col-major 布局
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:

View File

@@ -18,3 +18,9 @@ def register():
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
# noinspection PyUnresolvedReferences
from unilabos.resources.bioyond.decks import (
BIOYOND_PolymerReactionStation_Deck,
BIOYOND_PolymerPreparationStation_Deck,
BIOYOND_YB_Deck,
)

View File

@@ -13,6 +13,9 @@ if TYPE_CHECKING:
from pylabrobot.resources import Resource as PLRResource
EXTRA_CLASS = "unilabos_resource_class"
class ResourceDictPositionSize(BaseModel):
depth: float = Field(description="Depth", default=0.0) # z
width: float = Field(description="Width", default=0.0) # x
@@ -338,6 +341,7 @@ class ResourceTreeSet(object):
"deck": "deck",
"tip_rack": "tip_rack",
"tip_spot": "tip_spot",
"tip": "tip", # 添加 tip 类型支持
"tube": "tube",
"bottle_carrier": "bottle_carrier",
}
@@ -393,7 +397,7 @@ class ResourceTreeSet(object):
"parent": parent_resource, # 直接传入 ResourceDict 对象
"parent_uuid": parent_uuid, # 使用 parent_uuid 而不是 parent 对象
"type": replace_plr_type(d.get("category", "")),
"class": d.get("class", ""),
"class": extra.get(EXTRA_CLASS, ""),
"position": pos,
"pose": pos,
"config": {
@@ -443,7 +447,7 @@ class ResourceTreeSet(object):
trees.append(tree_instance)
return cls(trees)
def to_plr_resources(self) -> List["PLRResource"]:
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]:
"""
将 ResourceTreeSet 转换为 PLR 资源列表
@@ -468,6 +472,7 @@ class ResourceTreeSet(object):
name_to_uuid[node.res_content.name] = node.res_content.uuid
all_states[node.res_content.name] = node.res_content.data
name_to_extra[node.res_content.name] = node.res_content.extra
name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass
for child in node.children:
collect_node_data(child, name_to_uuid, all_states, name_to_extra)
@@ -512,7 +517,10 @@ class ResourceTreeSet(object):
plr_dict = node_to_plr_dict(tree.root_node, has_model)
try:
sub_cls = find_subclass(plr_dict["type"], PLRResource)
if sub_cls is None:
if skip_devices and plr_dict["type"] == "device":
logger.info(f"跳过更新 {plr_dict['name']} 设备是class")
continue
elif sub_cls is None:
raise ValueError(
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
)
@@ -520,6 +528,10 @@ class ResourceTreeSet(object):
if "category" not in spec.parameters:
plr_dict.pop("category", None)
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
from pylabrobot.resources import Coordinate
from pylabrobot.serializer import deserialize
location = cast(Coordinate, deserialize(plr_dict["location"]))
plr_resource.location = location
plr_resource.load_all_state(all_states)
# 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra
tracker.loop_set_uuid(plr_resource, name_to_uuid)
@@ -986,7 +998,7 @@ class DeviceNodeResourceTracker(object):
extra = name_to_extra_map[resource_name]
self.set_resource_extra(res, extra)
if len(extra):
logger.debug(f"设置资源Extra: {resource_name} -> {extra}")
logger.trace(f"设置资源Extra: {resource_name} -> {extra}")
return 1
return 0

View File

@@ -884,6 +884,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
parent_appended = True
# 加载状态
original_instance.location = plr_resource.location
original_instance.rotation = plr_resource.rotation
original_instance.barcode = plr_resource.barcode
original_instance.load_all_state(states)
child_count = len(original_instance.get_all_children())
self.lab_logger().info(
@@ -1319,19 +1322,32 @@ class BaseROS2DeviceNode(Node, Generic[T]):
resource_inputs = action_kwargs[k] if is_sequence else [action_kwargs[k]]
# 批量查询资源
queried_resources = []
for resource_data in resource_inputs:
queried_resources: list = [None] * len(resource_inputs)
uuid_indices: list[tuple[int, str, dict]] = [] # (index, uuid, resource_data)
# 第一遍处理没有uuid的资源收集有uuid的资源信息
for idx, resource_data in enumerate(resource_inputs):
unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid")
if unilabos_uuid is None:
plr_resource = await self.get_resource_with_dir(
resource_id=resource_data["id"], with_children=True
)
if "sample_id" in resource_data:
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
queried_resources[idx] = plr_resource
else:
resource_tree = await self.get_resource([unilabos_uuid])
plr_resource = resource_tree.to_plr_resources()[0]
if "sample_id" in resource_data:
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
queried_resources.append(plr_resource)
uuid_indices.append((idx, unilabos_uuid, resource_data))
# 第二遍批量查询有uuid的资源
if uuid_indices:
uuids = [item[1] for item in uuid_indices]
resource_tree = await self.get_resource(uuids)
plr_resources = resource_tree.to_plr_resources()
for i, (idx, _, resource_data) in enumerate(uuid_indices):
plr_resource = plr_resources[i]
if "sample_id" in resource_data:
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
queried_resources[idx] = plr_resource
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")

View File

@@ -808,6 +808,7 @@ class HostNode(BaseROS2DeviceNode):
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {action_kwargs}")
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
action_client.wait_for_server()
goal_uuid_obj = UUID(uuid=list(u.bytes))

View File

@@ -6,8 +6,6 @@ from typing import List, Dict, Any, Optional, TYPE_CHECKING
import rclpy
from rosidl_runtime_py import message_to_ordereddict
from unilabos_msgs.msg import Resource
from unilabos_msgs.srv import ResourceUpdate
from unilabos.messages import * # type: ignore # protocol names
from rclpy.action import ActionServer, ActionClient
@@ -15,7 +13,6 @@ from rclpy.action.server import ServerGoalHandle
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos.compile import action_protocol_generators
from unilabos.resources.graphio import nested_dict_to_list
from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import (
get_action_type,
@@ -231,15 +228,15 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
try:
# 统一处理单个或多个资源
resource_id = (
protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"]
protocol_kwargs[k]["id"]
if v == "unilabos_msgs/Resource"
else protocol_kwargs[k][0]["id"]
)
resource_uuid = protocol_kwargs[k].get("uuid", None)
r = SerialCommand_Request()
r.command = json.dumps({"id": resource_id, "uuid": resource_uuid, "with_children": True})
# 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients[
"resource_get"
].call_async(
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(
r
) # type: ignore
raw_data = json.loads(response.response)
@@ -307,12 +304,52 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
# 向Host更新物料当前状态
for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
r = ResourceUpdate.Request()
r.resources = [
convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k])
]
response = await self._resource_clients["resource_update"].call_async(r)
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue
self.lab_logger().info(f"更新资源状态: {k}")
try:
# 去重:使用 seen 集合获取唯一的资源对象
seen = set()
unique_resources = []
# 获取资源数据,统一转换为列表
resource_data = protocol_kwargs[k]
is_sequence = v != "unilabos_msgs/Resource"
if not is_sequence:
resource_list = [resource_data] if isinstance(resource_data, dict) else resource_data
else:
# 处理序列类型,可能是嵌套列表
resource_list = []
if isinstance(resource_data, list):
for item in resource_data:
if isinstance(item, list):
resource_list.extend(item)
else:
resource_list.append(item)
else:
resource_list = [resource_data]
for res_data in resource_list:
if not isinstance(res_data, dict):
continue
res_name = res_data.get("id") or res_data.get("name")
if not res_name:
continue
# 使用 resource_tracker 获取本地 PLR 实例
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
# 获取父资源
res = self.resource_tracker.parent_resource(plr)
if id(res) not in seen:
seen.add(id(res))
unique_resources.append(res)
# 使用新的资源树接口更新
if unique_resources:
await self.update_resource(unique_resources)
except Exception as e:
self.lab_logger().error(f"资源更新失败: {e}")
self.lab_logger().error(traceback.format_exc())
# 设置成功状态和返回值
execution_success = True

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"id": "Liquid_Sensor_1",
"name": "XKC Sensor",
"children": [],
"parent": null,
"type": "device",
"class": "sensor.xkc_rs485",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"port": "/dev/tty.usbserial-3110",
"baudrate": 9600,
"device_id": 1,
"threshold": 300,
"timeout": 3.0
},
"data": {
"level": false,
"rssi": 0
}
}
],
"links": []
}

View File

@@ -0,0 +1,28 @@
{
"nodes": [
{
"id": "ZDT_Motor",
"name": "ZDT Motor",
"children": [],
"parent": null,
"type": "device",
"class": "motor.zdt_x42",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"port": "/dev/tty.usbserial-3110",
"baudrate": 115200,
"device_id": 1,
"debug": true
},
"data": {
"position": 0,
"status": "idle"
}
}
],
"links": []
}

View File

@@ -1,3 +1,89 @@
"""
工作流转换模块 - JSON 到 WorkflowGraph 的转换流程
==================== 输入格式 (JSON) ====================
{
"workflow": [
{"action": "transfer_liquid", "action_args": {"sources": "cell_lines", "targets": "Liquid_1", "asp_vol": 100.0, "dis_vol": 74.75, ...}},
...
],
"reagent": {
"cell_lines": {"slot": 4, "well": ["A1", "A3", "A5"], "labware": "DRUG + YOYO-MEDIA"},
"Liquid_1": {"slot": 1, "well": ["A4", "A7", "A10"], "labware": "rep 1"},
...
}
}
==================== 转换步骤 ====================
第一步: 按 slot 去重创建 create_resource 节点(创建板子)
--------------------------------------------------------------------------------
- 遍历所有 reagent按 slot 去重,为每个唯一的 slot 创建一个板子
- 生成参数:
res_id: plate_slot_{slot}
device_id: /PRCXI
class_name: PRCXI_BioER_96_wellplate
parent: /PRCXI/PRCXI_Deck/T{slot}
slot_on_deck: "{slot}"
- 输出端口: labware用于连接 set_liquid_from_plate
- 控制流: create_resource 之间通过 ready 端口串联
示例: slot=1, slot=4 -> 创建 2 个 create_resource 节点
第二步: 为每个 reagent 创建 set_liquid_from_plate 节点(设置液体)
--------------------------------------------------------------------------------
- 遍历所有 reagent为每个试剂创建 set_liquid_from_plate 节点
- 生成参数:
plate: [](通过连接传递,来自 create_resource 的 labware
well_names: ["A1", "A3", "A5"](来自 reagent 的 well 数组)
liquid_names: ["cell_lines", "cell_lines", "cell_lines"](与 well 数量一致)
volumes: [1e5, 1e5, 1e5](与 well 数量一致,默认体积)
- 输入连接: create_resource (labware) -> set_liquid_from_plate (input_plate)
- 输出端口: output_wells用于连接 transfer_liquid
- 控制流: set_liquid_from_plate 连接在所有 create_resource 之后,通过 ready 端口串联
第三步: 解析 workflow创建 transfer_liquid 等动作节点
--------------------------------------------------------------------------------
- 遍历 workflow 数组,为每个动作创建步骤节点
- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates
- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组
例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 []
- 输入连接: set_liquid_from_plate (output_wells) -> transfer_liquid (sources_identifier / targets_identifier)
- 输出端口: sources_out, targets_out用于连接下一个 transfer_liquid
==================== 连接关系图 ====================
控制流 (ready 端口串联):
create_resource_1 -> create_resource_2 -> ... -> set_liquid_1 -> set_liquid_2 -> ... -> transfer_liquid_1 -> transfer_liquid_2 -> ...
物料流:
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
(slot=1) (cell_lines) (input_plate) (sources_identifier) (sources_identifier)
(slot=4) (Liquid_1) (targets_identifier) (targets_identifier)
==================== 端口映射 ====================
create_resource:
输出: labware
set_liquid_from_plate:
输入: input_plate
输出: output_plate, output_wells
transfer_liquid:
输入: sources -> sources_identifier, targets -> targets_identifier
输出: sources -> sources_out, targets -> targets_out
==================== 校验规则 ====================
- 检查 sources/targets 是否在 reagent 中定义
- 检查 sources 和 targets 的 wells 数量是否匹配
- 检查参数数组长度是否与 wells 数量一致
- 如有问题,在 footer 中添加 [WARN: ...] 标记
"""
import re
import uuid
@@ -8,6 +94,28 @@ from typing import Dict, List, Any, Tuple, Optional
Json = Dict[str, Any]
# ==================== 默认配置 ====================
# create_resource 节点默认参数
CREATE_RESOURCE_DEFAULTS = {
"device_id": "/PRCXI",
"parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值
"class_name": "PRCXI_BioER_96_wellplate",
}
# 默认液体体积 (uL)
DEFAULT_LIQUID_VOLUME = 1e5
# 参数重命名映射:单数 -> 复数(用于 transfer_liquid 等动作)
PARAM_RENAME_MAPPING = {
"asp_vol": "asp_vols",
"dis_vol": "dis_vols",
"asp_flow_rate": "asp_flow_rates",
"dis_flow_rate": "dis_flow_rates",
}
# ---------------- Graph ----------------
@@ -228,7 +336,7 @@ def refactor_data(
def build_protocol_graph(
labware_info: List[Dict[str, Any]],
labware_info: Dict[str, Dict[str, Any]],
protocol_steps: List[Dict[str, Any]],
workstation_name: str,
action_resource_mapping: Optional[Dict[str, str]] = None,
@@ -236,112 +344,227 @@ def build_protocol_graph(
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
Args:
labware_info: labware 信息字典
labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...}
protocol_steps: 协议步骤列表
workstation_name: 工作站名称
action_resource_mapping: action 到 resource_name 的映射字典,可选
"""
G = WorkflowGraph()
resource_last_writer = {}
resource_last_writer = {} # reagent_name -> "node_id:port"
slot_to_create_resource = {} # slot -> create_resource node_id
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
# 有机化学&移液站协议图构建
WORKSTATION_ID = workstation_name
# 为所有labware创建资源节点
res_index = 0
# ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
# 收集所有唯一的 slot
slots_info = {} # slot -> {labware, res_id}
for labware_id, item in labware_info.items():
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
node_id = str(uuid.uuid4())
slot = str(item.get("slot", ""))
if slot and slot not in slots_info:
res_id = f"plate_slot_{slot}"
slots_info[slot] = {
"labware": item.get("labware", ""),
"res_id": res_id,
}
# 判断节点类型
if "Rack" in str(labware_id) or "Tip" in str(labware_id):
lab_node_type = "Labware"
description = f"Prepare Labware: {labware_id}"
liquid_type = []
liquid_volume = []
elif item.get("type") == "hardware" or "reactor" in str(labware_id).lower():
if "reactor" not in str(labware_id).lower():
continue
lab_node_type = "Sample"
description = f"Prepare Reactor: {labware_id}"
liquid_type = []
liquid_volume = []
else:
lab_node_type = "Reagent"
description = f"Add Reagent to Flask: {labware_id}"
liquid_type = [labware_id]
liquid_volume = [1e5]
# 为每个唯一的 slot 创建 create_resource 节点
res_index = 0
last_create_resource_id = None
for slot, info in slots_info.items():
node_id = str(uuid.uuid4())
res_id = info["res_id"]
res_index += 1
G.add_node(
node_id,
template_name="create_resource",
resource_name="host_node",
name=f"Res {res_index}",
description=description,
lab_node_type=lab_node_type,
name=f"Plate {res_index}",
description=f"Create plate on slot {slot}",
lab_node_type="Labware",
footer="create_resource-host_node",
param={
"res_id": labware_id,
"device_id": WORKSTATION_ID,
"class_name": "container",
"parent": WORKSTATION_ID,
"res_id": res_id,
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
"class_name": CREATE_RESOURCE_DEFAULTS["class_name"],
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
"liquid_input_slot": [-1],
"liquid_type": liquid_type,
"liquid_volume": liquid_volume,
"slot_on_deck": "",
"slot_on_deck": slot,
},
)
resource_last_writer[labware_id] = f"{node_id}:labware"
slot_to_create_resource[slot] = node_id
last_control_node_id = None
# create_resource 之间通过 ready 串联
if last_create_resource_id is not None:
G.add_edge(last_create_resource_id, node_id, source_port="ready", target_port="ready")
last_create_resource_id = node_id
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
set_liquid_index = 0
last_set_liquid_id = last_create_resource_id # set_liquid_from_plate 连接在 create_resource 之后
for labware_id, item in labware_info.items():
# 跳过 Tip/Rack 类型
if "Rack" in str(labware_id) or "Tip" in str(labware_id):
continue
if item.get("type") == "hardware":
continue
slot = str(item.get("slot", ""))
wells = item.get("well", [])
if not wells or not slot:
continue
# res_id 不能有空格
res_id = str(labware_id).replace(" ", "_")
well_count = len(wells)
node_id = str(uuid.uuid4())
set_liquid_index += 1
G.add_node(
node_id,
template_name="set_liquid_from_plate",
resource_name="liquid_handler.prcxi",
name=f"SetLiquid {set_liquid_index}",
description=f"Set liquid: {labware_id}",
lab_node_type="Reagent",
footer="set_liquid_from_plate-liquid_handler.prcxi",
param={
"plate": [], # 通过连接传递
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
"liquid_names": [res_id] * well_count,
"volumes": [DEFAULT_LIQUID_VOLUME] * well_count,
},
)
# ready 连接:上一个节点 -> set_liquid_from_plate
if last_set_liquid_id is not None:
G.add_edge(last_set_liquid_id, node_id, source_port="ready", target_port="ready")
last_set_liquid_id = node_id
# 物料流create_resource 的 labware -> set_liquid_from_plate 的 input_plate
create_res_node_id = slot_to_create_resource.get(slot)
if create_res_node_id:
G.add_edge(create_res_node_id, node_id, source_port="labware", target_port="input_plate")
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
resource_last_writer[labware_id] = f"{node_id}:output_wells"
last_control_node_id = last_set_liquid_id
# 端口名称映射JSON 字段名 -> 实际 handle key
INPUT_PORT_MAPPING = {
"sources": "sources_identifier",
"targets": "targets_identifier",
"vessel": "vessel",
"to_vessel": "to_vessel",
"from_vessel": "from_vessel",
"reagent": "reagent",
"solvent": "solvent",
"compound": "compound",
}
OUTPUT_PORT_MAPPING = {
"sources": "sources_out", # 输出端口是 xxx_out
"targets": "targets_out", # 输出端口是 xxx_out
"vessel": "vessel_out",
"to_vessel": "to_vessel_out",
"from_vessel": "from_vessel_out",
"filtrate_vessel": "filtrate_out",
"reagent": "reagent",
"solvent": "solvent",
"compound": "compound",
}
# 需要根据 wells 数量扩展的参数列表(复数形式)
EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"]
# 处理协议步骤
for step in protocol_steps:
node_id = str(uuid.uuid4())
G.add_node(node_id, **step)
params = step.get("param", {}).copy() # 复制一份,避免修改原数据
connected_params = set() # 记录被连接的参数
warnings = [] # 收集警告信息
# 参数重命名:单数 -> 复数
for old_name, new_name in PARAM_RENAME_MAPPING.items():
if old_name in params:
params[new_name] = params.pop(old_name)
# 处理输入连接
for param_key, target_port in INPUT_PORT_MAPPING.items():
resource_name = params.get(param_key)
if resource_name and resource_name in resource_last_writer:
source_node, source_port = resource_last_writer[resource_name].split(":")
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
connected_params.add(param_key)
elif resource_name and resource_name not in resource_last_writer:
# 资源名在 labware_info 中不存在
warnings.append(f"{param_key}={resource_name} 未找到")
# 获取 targets 对应的 wells 数量,用于扩展参数
targets_name = params.get("targets")
sources_name = params.get("sources")
targets_wells_count = 1
sources_wells_count = 1
if targets_name and targets_name in labware_info:
target_wells = labware_info[targets_name].get("well", [])
targets_wells_count = len(target_wells) if target_wells else 1
elif targets_name:
warnings.append(f"targets={targets_name} 未在 reagent 中定义")
if sources_name and sources_name in labware_info:
source_wells = labware_info[sources_name].get("well", [])
sources_wells_count = len(source_wells) if source_wells else 1
elif sources_name:
warnings.append(f"sources={sources_name} 未在 reagent 中定义")
# 检查 sources 和 targets 的 wells 数量是否匹配
if targets_wells_count != sources_wells_count and targets_name and sources_name:
warnings.append(f"wells 数量不匹配: sources={sources_wells_count}, targets={targets_wells_count}")
# 使用 targets 的 wells 数量来扩展参数
wells_count = targets_wells_count
# 扩展单值参数为数组(根据 targets 的 wells 数量)
for expand_param in EXPAND_BY_WELLS_PARAMS:
if expand_param in params:
value = params[expand_param]
# 如果是单个值,扩展为数组
if not isinstance(value, list):
params[expand_param] = [value] * wells_count
# 如果已经是数组但长度不对,记录警告
elif len(value) != wells_count:
warnings.append(f"{expand_param} 数量({len(value)})与 wells({wells_count})不匹配")
# 如果 sources/targets 已通过连接传递,将参数值改为空数组
for param_key in connected_params:
if param_key in params:
params[param_key] = []
# 更新 step 的 param 和 footer
step_copy = step.copy()
step_copy["param"] = params
# 如果有警告,修改 footer 添加警告标记(警告放前面)
if warnings:
original_footer = step.get("footer", "")
step_copy["footer"] = f"[WARN: {'; '.join(warnings)}] {original_footer}"
G.add_node(node_id, **step_copy)
# 控制流
if last_control_node_id is not None:
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
last_control_node_id = node_id
# 物料流
params = step.get("param", {})
input_resources_possible_names = [
"vessel",
"to_vessel",
"from_vessel",
"reagent",
"solvent",
"compound",
"sources",
"targets",
]
for target_port in input_resources_possible_names:
resource_name = params.get(target_port)
if resource_name and resource_name in resource_last_writer:
source_node, source_port = resource_last_writer[resource_name].split(":")
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
output_resources = {
"vessel_out": params.get("vessel"),
"from_vessel_out": params.get("from_vessel"),
"to_vessel_out": params.get("to_vessel"),
"filtrate_out": params.get("filtrate_vessel"),
"reagent": params.get("reagent"),
"solvent": params.get("solvent"),
"compound": params.get("compound"),
"sources_out": params.get("sources"),
"targets_out": params.get("targets"),
}
for source_port, resource_name in output_resources.items():
# 处理输出:更新 resource_last_writer
for param_key, output_port in OUTPUT_PORT_MAPPING.items():
resource_name = step.get("param", {}).get(param_key) # 使用原始参数值
if resource_name:
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
resource_last_writer[resource_name] = f"{node_id}:{output_port}"
return G

View File

@@ -1,21 +1,68 @@
"""
JSON 工作流转换模块
提供从多种 JSON 格式转换为统一工作流格式的功能
支持的格式:
1. workflow/reagent 格式
2. steps_info/labware_info 格式
将 workflow/reagent 格式的 JSON 转换为统一工作流格式。
输入格式:
{
"workflow": [
{"action": "...", "action_args": {...}},
...
],
"reagent": {
"reagent_name": {"slot": int, "well": [...], "labware": "..."},
...
}
}
"""
import json
from os import PathLike
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple, Union
from unilabos.workflow.common import WorkflowGraph, build_protocol_graph
from unilabos.registry.registry import lab_registry
# ==================== 字段映射配置 ====================
# action 到 resource_name 的映射
ACTION_RESOURCE_MAPPING: Dict[str, str] = {
# 生物实验操作
"transfer_liquid": "liquid_handler.prcxi",
"transfer": "liquid_handler.prcxi",
"incubation": "incubator.prcxi",
"move_labware": "labware_mover.prcxi",
"oscillation": "shaker.prcxi",
# 有机化学操作
"HeatChillToTemp": "heatchill.chemputer",
"StopHeatChill": "heatchill.chemputer",
"StartHeatChill": "heatchill.chemputer",
"HeatChill": "heatchill.chemputer",
"Dissolve": "stirrer.chemputer",
"Transfer": "liquid_handler.chemputer",
"Evaporate": "rotavap.chemputer",
"Recrystallize": "reactor.chemputer",
"Filter": "filter.chemputer",
"Dry": "dryer.chemputer",
"Add": "liquid_handler.chemputer",
}
# action_args 字段到 parameters 字段的映射
# 格式: {"old_key": "new_key"}, 仅映射需要重命名的字段
ARGS_FIELD_MAPPING: Dict[str, str] = {
# 如果需要字段重命名,在这里配置
# "old_field_name": "new_field_name",
}
# 默认工作站名称
DEFAULT_WORKSTATION = "PRCXI"
# ==================== 核心转换函数 ====================
def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]:
"""
从 registry 获取指定设备和动作的 handles 配置
@@ -39,12 +86,10 @@ def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List
handles = action_config.get("handles", {})
if isinstance(handles, dict):
# 处理 input handles (作为 target)
for handle in handles.get("input", []):
handler_key = handle.get("handler_key", "")
if handler_key:
result["source"].append(handler_key)
# 处理 output handles (作为 source)
for handle in handles.get("output", []):
handler_key = handle.get("handler_key", "")
if handler_key:
@@ -69,12 +114,9 @@ def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]:
for edge in graph.edges:
left_uuid = edge.get("source")
right_uuid = edge.get("target")
# target_handle_key是target, right的输入节点入节点
# source_handle_key是source, left的输出节点出节点
right_source_conn_key = edge.get("target_handle_key", "")
left_target_conn_key = edge.get("source_handle_key", "")
# 获取源节点和目标节点信息
left_node = nodes.get(left_uuid, {})
right_node = nodes.get(right_uuid, {})
@@ -83,164 +125,93 @@ def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]:
right_res_name = right_node.get("resource_name", "")
right_template_name = right_node.get("template_name", "")
# 获取源节点的 output handles
left_node_handles = get_action_handles(left_res_name, left_template_name)
target_valid_keys = left_node_handles.get("target", [])
target_valid_keys.append("ready")
# 获取目标节点的 input handles
right_node_handles = get_action_handles(right_res_name, right_template_name)
source_valid_keys = right_node_handles.get("source", [])
source_valid_keys.append("ready")
# 如果节点配置了 output handles则 source_port 必须有效
# 验证目标节点right的输入端口
if not right_source_conn_key:
node_name = left_node.get("name", left_uuid[:8])
errors.append(f"节点 '{node_name}' source_handle_key 为空," f"应设置为: {source_valid_keys}")
node_name = right_node.get("name", right_uuid[:8])
errors.append(f"目标节点 '{node_name}'输入端口 (target_handle_key) 为空,应设置为: {source_valid_keys}")
elif right_source_conn_key not in source_valid_keys:
node_name = left_node.get("name", left_uuid[:8])
node_name = right_node.get("name", right_uuid[:8])
errors.append(
f"节点 '{node_name}' source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}"
f"目标节点 '{node_name}'输入端口 '{right_source_conn_key}' 不存在,支持的输入端口: {source_valid_keys}"
)
# 如果节点配置了 input handles则 target_port 必须有效
# 验证源节点left的输出端口
if not left_target_conn_key:
node_name = right_node.get("name", right_uuid[:8])
errors.append(f"目标节点 '{node_name}' target_handle_key 为空," f"应设置为: {target_valid_keys}")
node_name = left_node.get("name", left_uuid[:8])
errors.append(f"节点 '{node_name}'输出端口 (source_handle_key) 为空,应设置为: {target_valid_keys}")
elif left_target_conn_key not in target_valid_keys:
node_name = right_node.get("name", right_uuid[:8])
node_name = left_node.get("name", left_uuid[:8])
errors.append(
f"目标节点 '{node_name}' target 端点 '{left_target_conn_key}' 不存在,"
f"支持的端点: {target_valid_keys}"
f"节点 '{node_name}'输出端口 '{left_target_conn_key}' 不存在,支持的输出端口: {target_valid_keys}"
)
return len(errors) == 0, errors
# action 到 resource_name 的映射
ACTION_RESOURCE_MAPPING: Dict[str, str] = {
# 生物实验操作
"transfer_liquid": "liquid_handler.prcxi",
"transfer": "liquid_handler.prcxi",
"incubation": "incubator.prcxi",
"move_labware": "labware_mover.prcxi",
"oscillation": "shaker.prcxi",
# 有机化学操作
"HeatChillToTemp": "heatchill.chemputer",
"StopHeatChill": "heatchill.chemputer",
"StartHeatChill": "heatchill.chemputer",
"HeatChill": "heatchill.chemputer",
"Dissolve": "stirrer.chemputer",
"Transfer": "liquid_handler.chemputer",
"Evaporate": "rotavap.chemputer",
"Recrystallize": "reactor.chemputer",
"Filter": "filter.chemputer",
"Dry": "dryer.chemputer",
"Add": "liquid_handler.chemputer",
}
def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def normalize_workflow_steps(workflow: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
不同格式的步骤数据规范化为统一格式
workflow 格式的步骤数据规范化
支持的输入格式
- action + parameters
- action + action_args
- operation + parameters
输入格式:
[{"action": "...", "action_args": {...}}, ...]
输出格式:
[{"action": "...", "parameters": {...}, "step_number": int}, ...]
Args:
data: 原始步骤数据列表
workflow: workflow 数组
Returns:
规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...]
规范化后的步骤列表
"""
normalized = []
for idx, step in enumerate(data):
# 获取动作名称(支持 action 或 operation 字段)
action = step.get("action") or step.get("operation")
for idx, step in enumerate(workflow):
action = step.get("action")
if not action:
continue
# 获取参数(支持 parameters 或 action_args 字段)
raw_params = step.get("parameters") or step.get("action_args") or {}
params = dict(raw_params)
# 获取参数: action_args
raw_params = step.get("action_args", {})
params = {}
# 规范化 source/target -> sources/targets
if "source" in raw_params and "sources" not in raw_params:
params["sources"] = raw_params["source"]
if "target" in raw_params and "targets" not in raw_params:
params["targets"] = raw_params["target"]
# 应用字段映射
for key, value in raw_params.items():
mapped_key = ARGS_FIELD_MAPPING.get(key, key)
params[mapped_key] = value
# 获取描述(支持 description 或 purpose 字段)
description = step.get("description") or step.get("purpose")
step_dict = {
"action": action,
"parameters": params,
"step_number": idx + 1,
}
# 获取步骤编号(优先使用原始数据中的 step_number否则使用索引+1
step_number = step.get("step_number", idx + 1)
step_dict = {"action": action, "parameters": params, "step_number": step_number}
if description:
step_dict["description"] = description
# 保留描述字段
if "description" in step:
step_dict["description"] = step["description"]
normalized.append(step_dict)
return normalized
def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
"""
将不同格式的 labware 数据规范化为统一的字典格式
支持的输入格式:
- reagent_name + material_name + positions
- name + labware + slot
Args:
data: 原始 labware 数据列表
Returns:
规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...}
"""
labware = {}
for item in data:
# 获取 key 名称(优先使用 reagent_name其次是 material_name 或 name
reagent_name = item.get("reagent_name")
key = reagent_name or item.get("material_name") or item.get("name")
if not key:
continue
key = str(key)
# 处理重复 key自动添加后缀
idx = 1
original_key = key
while key in labware:
idx += 1
key = f"{original_key}_{idx}"
labware[key] = {
"slot": item.get("positions") or item.get("slot"),
"labware": item.get("material_name") or item.get("labware"),
"well": item.get("well", []),
"type": item.get("type", "reagent"),
"role": item.get("role", ""),
"name": key,
}
return labware
def convert_from_json(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = "PRCXi",
workstation_name: str = DEFAULT_WORKSTATION,
validate: bool = True,
) -> WorkflowGraph:
"""
从 JSON 数据或文件转换为 WorkflowGraph
支持的 JSON 格式
1. {"workflow": [...], "reagent": {...}} - 直接格式
2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式
JSON 格式:
{"workflow": [...], "reagent": {...}}
Args:
data: JSON 文件路径、字典数据、或 JSON 字符串
@@ -251,7 +222,7 @@ def convert_from_json(
WorkflowGraph: 构建好的工作流图
Raises:
ValueError: 不支持的 JSON 格式 或 句柄校验失败
ValueError: 不支持的 JSON 格式
FileNotFoundError: 文件不存在
json.JSONDecodeError: JSON 解析失败
"""
@@ -262,7 +233,6 @@ def convert_from_json(
with path.open("r", encoding="utf-8") as fp:
json_data = json.load(fp)
elif isinstance(data, str):
# 尝试作为 JSON 字符串解析
json_data = json.loads(data)
else:
raise FileNotFoundError(f"文件不存在: {data}")
@@ -271,30 +241,24 @@ def convert_from_json(
else:
raise TypeError(f"不支持的数据类型: {type(data)}")
# 根据格式解析数据
if "workflow" in json_data and "reagent" in json_data:
# 格式1: workflow/reagent已经是规范格式
protocol_steps = json_data["workflow"]
labware_info = json_data["reagent"]
elif "steps_info" in json_data and "labware_info" in json_data:
# 格式2: steps_info/labware_info需要规范化
protocol_steps = normalize_steps(json_data["steps_info"])
labware_info = normalize_labware(json_data["labware_info"])
elif "steps" in json_data and "labware" in json_data:
# 格式3: steps/labware另一种常见格式
protocol_steps = normalize_steps(json_data["steps"])
if isinstance(json_data["labware"], list):
labware_info = normalize_labware(json_data["labware"])
else:
labware_info = json_data["labware"]
else:
# 校验格式
if "workflow" not in json_data or "reagent" not in json_data:
raise ValueError(
"不支持的 JSON 格式。支持的格式\n"
"1. {'workflow': [...], 'reagent': {...}}\n"
"2. {'steps_info': [...], 'labware_info': [...]}\n"
"3. {'steps': [...], 'labware': [...]}"
"不支持的 JSON 格式。请使用标准格式:\n"
'{"workflow": [{"action": "...", "action_args": {...}}, ...], '
'"reagent": {"name": {"slot": int, "well": [...], "labware": "..."}, ...}}'
)
# 提取数据
workflow = json_data["workflow"]
reagent = json_data["reagent"]
# 规范化步骤数据
protocol_steps = normalize_workflow_steps(workflow)
# reagent 已经是字典格式,直接使用
labware_info = reagent
# 构建工作流图
graph = build_protocol_graph(
labware_info=labware_info,
@@ -317,7 +281,7 @@ def convert_from_json(
def convert_json_to_node_link(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = "PRCXi",
workstation_name: str = DEFAULT_WORKSTATION,
) -> Dict[str, Any]:
"""
将 JSON 数据转换为 node-link 格式的字典
@@ -335,7 +299,7 @@ def convert_json_to_node_link(
def convert_json_to_workflow_list(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = "PRCXi",
workstation_name: str = DEFAULT_WORKSTATION,
) -> List[Dict[str, Any]]:
"""
将 JSON 数据转换为工作流列表格式
@@ -349,8 +313,3 @@ def convert_json_to_workflow_list(
"""
graph = convert_from_json(data, workstation_name)
return graph.to_dict()
# 为了向后兼容,保留下划线前缀的别名
_normalize_steps = normalize_steps
_normalize_labware = normalize_labware

View File

@@ -0,0 +1,356 @@
"""
JSON 工作流转换模块
提供从多种 JSON 格式转换为统一工作流格式的功能。
支持的格式:
1. workflow/reagent 格式
2. steps_info/labware_info 格式
"""
import json
from os import PathLike
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from unilabos.workflow.common import WorkflowGraph, build_protocol_graph
from unilabos.registry.registry import lab_registry
def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]:
"""
从 registry 获取指定设备和动作的 handles 配置
Args:
resource_name: 设备资源名称,如 "liquid_handler.prcxi"
template_name: 动作模板名称,如 "transfer_liquid"
Returns:
包含 source 和 target handler_keys 的字典:
{"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]}
"""
result = {"source": [], "target": []}
device_info = lab_registry.device_type_registry.get(resource_name, {})
if not device_info:
return result
action_mappings = device_info.get("class", {}).get("action_value_mappings", {})
action_config = action_mappings.get(template_name, {})
handles = action_config.get("handles", {})
if isinstance(handles, dict):
# 处理 input handles (作为 target)
for handle in handles.get("input", []):
handler_key = handle.get("handler_key", "")
if handler_key:
result["source"].append(handler_key)
# 处理 output handles (作为 source)
for handle in handles.get("output", []):
handler_key = handle.get("handler_key", "")
if handler_key:
result["target"].append(handler_key)
return result
def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]:
"""
校验工作流图中所有边的句柄配置是否正确
Args:
graph: 工作流图对象
Returns:
(is_valid, errors): 是否有效,错误信息列表
"""
errors = []
nodes = graph.nodes
for edge in graph.edges:
left_uuid = edge.get("source")
right_uuid = edge.get("target")
# target_handle_key是target, right的输入节点入节点
# source_handle_key是source, left的输出节点出节点
right_source_conn_key = edge.get("target_handle_key", "")
left_target_conn_key = edge.get("source_handle_key", "")
# 获取源节点和目标节点信息
left_node = nodes.get(left_uuid, {})
right_node = nodes.get(right_uuid, {})
left_res_name = left_node.get("resource_name", "")
left_template_name = left_node.get("template_name", "")
right_res_name = right_node.get("resource_name", "")
right_template_name = right_node.get("template_name", "")
# 获取源节点的 output handles
left_node_handles = get_action_handles(left_res_name, left_template_name)
target_valid_keys = left_node_handles.get("target", [])
target_valid_keys.append("ready")
# 获取目标节点的 input handles
right_node_handles = get_action_handles(right_res_name, right_template_name)
source_valid_keys = right_node_handles.get("source", [])
source_valid_keys.append("ready")
# 如果节点配置了 output handles则 source_port 必须有效
if not right_source_conn_key:
node_name = left_node.get("name", left_uuid[:8])
errors.append(f"源节点 '{node_name}' 的 source_handle_key 为空," f"应设置为: {source_valid_keys}")
elif right_source_conn_key not in source_valid_keys:
node_name = left_node.get("name", left_uuid[:8])
errors.append(
f"源节点 '{node_name}' 的 source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}"
)
# 如果节点配置了 input handles则 target_port 必须有效
if not left_target_conn_key:
node_name = right_node.get("name", right_uuid[:8])
errors.append(f"目标节点 '{node_name}' 的 target_handle_key 为空," f"应设置为: {target_valid_keys}")
elif left_target_conn_key not in target_valid_keys:
node_name = right_node.get("name", right_uuid[:8])
errors.append(
f"目标节点 '{node_name}' 的 target 端点 '{left_target_conn_key}' 不存在,"
f"支持的端点: {target_valid_keys}"
)
return len(errors) == 0, errors
# action 到 resource_name 的映射
ACTION_RESOURCE_MAPPING: Dict[str, str] = {
# 生物实验操作
"transfer_liquid": "liquid_handler.prcxi",
"transfer": "liquid_handler.prcxi",
"incubation": "incubator.prcxi",
"move_labware": "labware_mover.prcxi",
"oscillation": "shaker.prcxi",
# 有机化学操作
"HeatChillToTemp": "heatchill.chemputer",
"StopHeatChill": "heatchill.chemputer",
"StartHeatChill": "heatchill.chemputer",
"HeatChill": "heatchill.chemputer",
"Dissolve": "stirrer.chemputer",
"Transfer": "liquid_handler.chemputer",
"Evaporate": "rotavap.chemputer",
"Recrystallize": "reactor.chemputer",
"Filter": "filter.chemputer",
"Dry": "dryer.chemputer",
"Add": "liquid_handler.chemputer",
}
def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
将不同格式的步骤数据规范化为统一格式
支持的输入格式:
- action + parameters
- action + action_args
- operation + parameters
Args:
data: 原始步骤数据列表
Returns:
规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...]
"""
normalized = []
for idx, step in enumerate(data):
# 获取动作名称(支持 action 或 operation 字段)
action = step.get("action") or step.get("operation")
if not action:
continue
# 获取参数(支持 parameters 或 action_args 字段)
raw_params = step.get("parameters") or step.get("action_args") or {}
params = dict(raw_params)
# 规范化 source/target -> sources/targets
if "source" in raw_params and "sources" not in raw_params:
params["sources"] = raw_params["source"]
if "target" in raw_params and "targets" not in raw_params:
params["targets"] = raw_params["target"]
# 获取描述(支持 description 或 purpose 字段)
description = step.get("description") or step.get("purpose")
# 获取步骤编号(优先使用原始数据中的 step_number否则使用索引+1
step_number = step.get("step_number", idx + 1)
step_dict = {"action": action, "parameters": params, "step_number": step_number}
if description:
step_dict["description"] = description
normalized.append(step_dict)
return normalized
def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
"""
将不同格式的 labware 数据规范化为统一的字典格式
支持的输入格式:
- reagent_name + material_name + positions
- name + labware + slot
Args:
data: 原始 labware 数据列表
Returns:
规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...}
"""
labware = {}
for item in data:
# 获取 key 名称(优先使用 reagent_name其次是 material_name 或 name
reagent_name = item.get("reagent_name")
key = reagent_name or item.get("material_name") or item.get("name")
if not key:
continue
key = str(key)
# 处理重复 key自动添加后缀
idx = 1
original_key = key
while key in labware:
idx += 1
key = f"{original_key}_{idx}"
labware[key] = {
"slot": item.get("positions") or item.get("slot"),
"labware": item.get("material_name") or item.get("labware"),
"well": item.get("well", []),
"type": item.get("type", "reagent"),
"role": item.get("role", ""),
"name": key,
}
return labware
def convert_from_json(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = "PRCXi",
validate: bool = True,
) -> WorkflowGraph:
"""
从 JSON 数据或文件转换为 WorkflowGraph
支持的 JSON 格式:
1. {"workflow": [...], "reagent": {...}} - 直接格式
2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式
Args:
data: JSON 文件路径、字典数据、或 JSON 字符串
workstation_name: 工作站名称,默认 "PRCXi"
validate: 是否校验句柄配置,默认 True
Returns:
WorkflowGraph: 构建好的工作流图
Raises:
ValueError: 不支持的 JSON 格式 或 句柄校验失败
FileNotFoundError: 文件不存在
json.JSONDecodeError: JSON 解析失败
"""
# 处理输入数据
if isinstance(data, (str, PathLike)):
path = Path(data)
if path.exists():
with path.open("r", encoding="utf-8") as fp:
json_data = json.load(fp)
elif isinstance(data, str):
# 尝试作为 JSON 字符串解析
json_data = json.loads(data)
else:
raise FileNotFoundError(f"文件不存在: {data}")
elif isinstance(data, dict):
json_data = data
else:
raise TypeError(f"不支持的数据类型: {type(data)}")
# 根据格式解析数据
if "workflow" in json_data and "reagent" in json_data:
# 格式1: workflow/reagent已经是规范格式
protocol_steps = json_data["workflow"]
labware_info = json_data["reagent"]
elif "steps_info" in json_data and "labware_info" in json_data:
# 格式2: steps_info/labware_info需要规范化
protocol_steps = normalize_steps(json_data["steps_info"])
labware_info = normalize_labware(json_data["labware_info"])
elif "steps" in json_data and "labware" in json_data:
# 格式3: steps/labware另一种常见格式
protocol_steps = normalize_steps(json_data["steps"])
if isinstance(json_data["labware"], list):
labware_info = normalize_labware(json_data["labware"])
else:
labware_info = json_data["labware"]
else:
raise ValueError(
"不支持的 JSON 格式。支持的格式:\n"
"1. {'workflow': [...], 'reagent': {...}}\n"
"2. {'steps_info': [...], 'labware_info': [...]}\n"
"3. {'steps': [...], 'labware': [...]}"
)
# 构建工作流图
graph = build_protocol_graph(
labware_info=labware_info,
protocol_steps=protocol_steps,
workstation_name=workstation_name,
action_resource_mapping=ACTION_RESOURCE_MAPPING,
)
# 校验句柄配置
if validate:
is_valid, errors = validate_workflow_handles(graph)
if not is_valid:
import warnings
for error in errors:
warnings.warn(f"句柄校验警告: {error}")
return graph
def convert_json_to_node_link(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = "PRCXi",
) -> Dict[str, Any]:
"""
将 JSON 数据转换为 node-link 格式的字典
Args:
data: JSON 文件路径、字典数据、或 JSON 字符串
workstation_name: 工作站名称,默认 "PRCXi"
Returns:
Dict: node-link 格式的工作流数据
"""
graph = convert_from_json(data, workstation_name)
return graph.to_node_link_dict()
def convert_json_to_workflow_list(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = "PRCXi",
) -> List[Dict[str, Any]]:
"""
将 JSON 数据转换为工作流列表格式
Args:
data: JSON 文件路径、字典数据、或 JSON 字符串
workstation_name: 工作站名称,默认 "PRCXi"
Returns:
List: 工作流节点列表
"""
graph = convert_from_json(data, workstation_name)
return graph.to_dict()
# 为了向后兼容,保留下划线前缀的别名
_normalize_steps = normalize_steps
_normalize_labware = normalize_labware

View File

@@ -2,7 +2,7 @@
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>unilabos_msgs</name>
<version>0.10.16</version>
<version>0.10.17</version>
<description>ROS2 Messages package for unilabos devices</description>
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>