mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-23 04:50:01 +00:00
Compare commits
43 Commits
865dd87556
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e212dc7781 | ||
|
|
96c4be17dc | ||
|
|
44afc7733b | ||
|
|
a34ffcaeb9 | ||
|
|
70c6685283 | ||
|
|
7027bd5ed1 | ||
|
|
57f5c8752d | ||
|
|
827169827a | ||
|
|
c4a2f68649 | ||
|
|
195fad9398 | ||
|
|
898ed5d34b | ||
|
|
60cbedc4b2 | ||
|
|
2d6a9f7db9 | ||
|
|
5dca3d8c3d | ||
|
|
37cbed722a | ||
|
|
132cffbe7c | ||
|
|
36e5ff804c | ||
|
|
eaf8ad5609 | ||
|
|
16122ad2fa | ||
|
|
d3fef85dd8 | ||
|
|
f77ac2a5e8 | ||
|
|
93ac55a65b | ||
|
|
af35debe38 | ||
|
|
d68fc5e380 | ||
|
|
f0ea32f163 | ||
|
|
3c8020813b | ||
|
|
97996d316f | ||
|
|
9815961a1f | ||
|
|
fe501c965f | ||
|
|
92bfb069d5 | ||
|
|
b61c818f7f | ||
|
|
47a29a0c2f | ||
|
|
9c6f7c7505 | ||
|
|
e4e4bfbe20 | ||
|
|
64c748d921 | ||
|
|
15ff0e9d30 | ||
|
|
f8a52860ad | ||
|
|
e30c01d54e | ||
|
|
37ec49f318 | ||
|
|
6bf57f18c1 | ||
|
|
c4a3be1498 | ||
|
|
e11070315d | ||
|
|
50ebcad9d7 |
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.19
|
||||
version: 0.11.1
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
@@ -54,7 +54,7 @@ requirements:
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.10.19
|
||||
- uni-lab::unilabos-env ==0.11.1
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.19
|
||||
version: 0.11.1
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.10.19
|
||||
version: 0.11.1
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
@@ -11,7 +11,7 @@ build:
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.10.19
|
||||
- uni-lab::unilabos ==0.11.1
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
|
||||
328
.cursor/rules/device-drivers.mdc
Normal file
328
.cursor/rules/device-drivers.mdc
Normal file
@@ -0,0 +1,328 @@
|
||||
---
|
||||
description: 设备驱动开发规范
|
||||
globs: ["unilabos/devices/**/*.py"]
|
||||
---
|
||||
|
||||
# 设备驱动开发规范
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
unilabos/devices/
|
||||
├── virtual/ # 虚拟设备(用于测试)
|
||||
│ ├── virtual_stirrer.py
|
||||
│ └── virtual_centrifuge.py
|
||||
├── liquid_handling/ # 液体处理设备
|
||||
├── balance/ # 天平设备
|
||||
├── hplc/ # HPLC设备
|
||||
├── pump_and_valve/ # 泵和阀门
|
||||
├── temperature/ # 温度控制设备
|
||||
├── workstation/ # 工作站(组合设备)
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 设备类完整模板
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import logging
|
||||
import time as time_module
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
class MyDevice:
|
||||
"""
|
||||
设备类描述
|
||||
|
||||
Attributes:
|
||||
device_id: 设备唯一标识
|
||||
config: 设备配置字典
|
||||
data: 设备状态数据
|
||||
"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str = None,
|
||||
config: Dict[str, Any] = None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
初始化设备
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
config: 配置字典
|
||||
**kwargs: 其他参数
|
||||
"""
|
||||
# 兼容不同调用方式
|
||||
if device_id is None and 'id' in kwargs:
|
||||
device_id = kwargs.pop('id')
|
||||
if config is None and 'config' in kwargs:
|
||||
config = kwargs.pop('config')
|
||||
|
||||
self.device_id = device_id or "unknown_device"
|
||||
self.config = config or {}
|
||||
self.data = {}
|
||||
|
||||
# 从config读取参数
|
||||
self.port = self.config.get('port') or kwargs.get('port', 'COM1')
|
||||
self._max_value = self.config.get('max_value', 1000.0)
|
||||
|
||||
# 初始化日志
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
|
||||
self.logger.info(f"设备 {self.device_id} 已创建")
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
"""
|
||||
ROS节点注入 - 在ROS节点创建后调用
|
||||
|
||||
Args:
|
||||
ros_node: ROS2设备节点实例
|
||||
"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""
|
||||
初始化设备 - 连接硬件、设置初始状态
|
||||
|
||||
Returns:
|
||||
bool: 初始化是否成功
|
||||
"""
|
||||
self.logger.info(f"初始化设备 {self.device_id}")
|
||||
|
||||
try:
|
||||
# 执行硬件初始化
|
||||
# await self._connect_hardware()
|
||||
|
||||
# 设置初始状态
|
||||
self.data.update({
|
||||
"status": "待机",
|
||||
"is_running": False,
|
||||
"current_value": 0.0,
|
||||
})
|
||||
|
||||
self.logger.info(f"设备 {self.device_id} 初始化完成")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"初始化失败: {e}")
|
||||
self.data["status"] = f"错误: {e}"
|
||||
return False
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
"""
|
||||
清理设备 - 断开连接、释放资源
|
||||
|
||||
Returns:
|
||||
bool: 清理是否成功
|
||||
"""
|
||||
self.logger.info(f"清理设备 {self.device_id}")
|
||||
|
||||
self.data.update({
|
||||
"status": "离线",
|
||||
"is_running": False,
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
# ==================== 设备动作 ====================
|
||||
|
||||
async def execute_action(
|
||||
self,
|
||||
param1: float,
|
||||
param2: str = "",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
执行设备动作
|
||||
|
||||
Args:
|
||||
param1: 参数1
|
||||
param2: 参数2(可选)
|
||||
|
||||
Returns:
|
||||
bool: 动作是否成功
|
||||
"""
|
||||
# 类型转换和验证
|
||||
try:
|
||||
param1 = float(param1)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.logger.error(f"参数类型错误: {e}")
|
||||
return False
|
||||
|
||||
# 参数验证
|
||||
if param1 > self._max_value:
|
||||
self.logger.error(f"参数超出范围: {param1} > {self._max_value}")
|
||||
return False
|
||||
|
||||
self.logger.info(f"执行动作: param1={param1}, param2={param2}")
|
||||
|
||||
# 更新状态
|
||||
self.data.update({
|
||||
"status": "运行中",
|
||||
"is_running": True,
|
||||
})
|
||||
|
||||
# 执行动作(带进度反馈)
|
||||
duration = 10.0 # 秒
|
||||
start_time = time_module.time()
|
||||
|
||||
while True:
|
||||
elapsed = time_module.time() - start_time
|
||||
remaining = max(0, duration - elapsed)
|
||||
progress = min(100, (elapsed / duration) * 100)
|
||||
|
||||
self.data.update({
|
||||
"status": f"运行中: {progress:.0f}%",
|
||||
"remaining_time": remaining,
|
||||
})
|
||||
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
await self._ros_node.sleep(1.0)
|
||||
|
||||
# 完成
|
||||
self.data.update({
|
||||
"status": "完成",
|
||||
"is_running": False,
|
||||
})
|
||||
|
||||
self.logger.info("动作执行完成")
|
||||
return True
|
||||
|
||||
# ==================== 状态属性 ====================
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""设备状态 - 自动发布为ROS Topic"""
|
||||
return self.data.get("status", "未知")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""是否正在运行"""
|
||||
return self.data.get("is_running", False)
|
||||
|
||||
@property
|
||||
def current_value(self) -> float:
|
||||
"""当前值"""
|
||||
return self.data.get("current_value", 0.0)
|
||||
|
||||
# ==================== 辅助方法 ====================
|
||||
|
||||
def get_device_info(self) -> Dict[str, Any]:
|
||||
"""获取设备信息"""
|
||||
return {
|
||||
"device_id": self.device_id,
|
||||
"status": self.status,
|
||||
"is_running": self.is_running,
|
||||
"current_value": self.current_value,
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"MyDevice({self.device_id}: {self.status})"
|
||||
```
|
||||
|
||||
## 关键规则
|
||||
|
||||
### 1. 参数处理
|
||||
|
||||
所有动作方法的参数都可能以字符串形式传入,必须进行类型转换:
|
||||
|
||||
```python
|
||||
async def my_action(self, value: float, **kwargs) -> bool:
|
||||
# 始终进行类型转换
|
||||
try:
|
||||
value = float(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.logger.error(f"参数类型错误: {e}")
|
||||
return False
|
||||
```
|
||||
|
||||
### 2. vessel 参数处理
|
||||
|
||||
vessel 参数可能是字符串ID或字典:
|
||||
|
||||
```python
|
||||
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||
if isinstance(vessel, dict):
|
||||
return vessel.get("id", "")
|
||||
return str(vessel) if vessel else ""
|
||||
```
|
||||
|
||||
### 3. 状态更新
|
||||
|
||||
使用 `self.data` 字典存储状态,属性读取状态:
|
||||
|
||||
```python
|
||||
# 更新状态
|
||||
self.data["status"] = "运行中"
|
||||
self.data["current_speed"] = 300.0
|
||||
|
||||
# 读取状态(通过属性)
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "待机")
|
||||
```
|
||||
|
||||
### 4. 异步等待
|
||||
|
||||
使用 ROS 节点的 sleep 方法:
|
||||
|
||||
```python
|
||||
# 正确
|
||||
await self._ros_node.sleep(1.0)
|
||||
|
||||
# 避免(除非在纯 Python 测试环境)
|
||||
await asyncio.sleep(1.0)
|
||||
```
|
||||
|
||||
### 5. 进度反馈
|
||||
|
||||
长时间运行的操作需要提供进度反馈:
|
||||
|
||||
```python
|
||||
while remaining > 0:
|
||||
progress = (elapsed / total_time) * 100
|
||||
self.data["status"] = f"运行中: {progress:.0f}%"
|
||||
self.data["remaining_time"] = remaining
|
||||
|
||||
await self._ros_node.sleep(1.0)
|
||||
```
|
||||
|
||||
## 虚拟设备
|
||||
|
||||
虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录:
|
||||
|
||||
- 类名以 `Virtual` 开头
|
||||
- 文件名以 `virtual_` 开头
|
||||
- 模拟真实设备的行为和时序
|
||||
- 使用表情符号增强日志可读性(可选)
|
||||
|
||||
## 工作站设备
|
||||
|
||||
工作站是组合多个设备的复杂设备:
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
"""组合工作站"""
|
||||
|
||||
async def execute_workflow(self, workflow: Dict[str, Any]) -> bool:
|
||||
"""执行工作流"""
|
||||
pass
|
||||
```
|
||||
|
||||
## 设备注册
|
||||
|
||||
设备类开发完成后,需要在注册表中注册:
|
||||
|
||||
1. 创建/编辑 `unilabos/registry/devices/my_category.yaml`
|
||||
2. 添加设备配置(参考 `virtual_device.yaml`)
|
||||
3. 运行 `--complete_registry` 自动生成 schema
|
||||
240
.cursor/rules/protocol-development.mdc
Normal file
240
.cursor/rules/protocol-development.mdc
Normal file
@@ -0,0 +1,240 @@
|
||||
---
|
||||
description: 协议编译器开发规范
|
||||
globs: ["unilabos/compile/**/*.py"]
|
||||
---
|
||||
|
||||
# 协议编译器开发规范
|
||||
|
||||
## 概述
|
||||
|
||||
协议编译器负责将高级实验操作(如 Stir、Add、Filter)编译为设备可执行的动作序列。
|
||||
|
||||
## 文件命名
|
||||
|
||||
- 位置: `unilabos/compile/`
|
||||
- 命名: `{operation}_protocol.py`
|
||||
- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py`
|
||||
|
||||
## 协议函数模板
|
||||
|
||||
```python
|
||||
from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
|
||||
from .utils.unit_parser import parse_time_input
|
||||
from .utils.vessel_parser import extract_vessel_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_{operation}_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: Union[str, dict],
|
||||
param1: Union[str, float] = "0",
|
||||
param2: float = 0.0,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成{操作}协议序列
|
||||
|
||||
Args:
|
||||
G: 物理拓扑图 (NetworkX DiGraph)
|
||||
vessel: 容器ID或Resource字典
|
||||
param1: 参数1(支持字符串单位,如 "5 min")
|
||||
param2: 参数2
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
List[Dict]: 动作序列
|
||||
|
||||
Raises:
|
||||
ValueError: 参数无效时
|
||||
"""
|
||||
# 1. 提取 vessel_id
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
|
||||
# 2. 验证参数
|
||||
if not vessel_id:
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
# 3. 解析参数(支持单位)
|
||||
parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0
|
||||
|
||||
# 4. 查找设备
|
||||
device_id = find_connected_device(G, vessel_id, device_type="my_device")
|
||||
|
||||
# 5. 生成动作序列
|
||||
action_sequence = []
|
||||
|
||||
action = {
|
||||
"device_id": device_id,
|
||||
"action_name": "my_action",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 始终使用字典格式
|
||||
"param1": float(parsed_param1),
|
||||
"param2": float(param2),
|
||||
}
|
||||
}
|
||||
action_sequence.append(action)
|
||||
|
||||
logger.info(f"生成协议: {len(action_sequence)} 个动作")
|
||||
return action_sequence
|
||||
|
||||
|
||||
def find_connected_device(
|
||||
G: nx.DiGraph,
|
||||
vessel_id: str,
|
||||
device_type: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
查找与容器相连的设备
|
||||
|
||||
Args:
|
||||
G: 拓扑图
|
||||
vessel_id: 容器ID
|
||||
device_type: 设备类型关键字
|
||||
|
||||
Returns:
|
||||
str: 设备ID
|
||||
"""
|
||||
# 查找所有匹配类型的设备
|
||||
device_nodes = []
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '') or ''
|
||||
if device_type.lower() in node_class.lower():
|
||||
device_nodes.append(node)
|
||||
|
||||
# 检查连接
|
||||
if vessel_id and device_nodes:
|
||||
for device in device_nodes:
|
||||
if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device):
|
||||
return device
|
||||
|
||||
# 返回第一个可用设备
|
||||
if device_nodes:
|
||||
return device_nodes[0]
|
||||
|
||||
# 默认设备
|
||||
return f"{device_type}_1"
|
||||
```
|
||||
|
||||
## 关键规则
|
||||
|
||||
### 1. vessel 参数处理
|
||||
|
||||
vessel 参数可能是字符串或字典,需要统一处理:
|
||||
|
||||
```python
|
||||
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||
"""提取vessel_id"""
|
||||
if isinstance(vessel, dict):
|
||||
# 可能是 {"id": "xxx"} 或完整 Resource 对象
|
||||
return vessel.get("id", list(vessel.values())[0].get("id", ""))
|
||||
return str(vessel) if vessel else ""
|
||||
```
|
||||
|
||||
### 2. action_kwargs 中的 vessel
|
||||
|
||||
始终使用 `{"id": vessel_id}` 格式传递 vessel:
|
||||
|
||||
```python
|
||||
# 正确
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 字符串ID包装为字典
|
||||
}
|
||||
|
||||
# 避免
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_resource, # 不要传递完整 Resource 对象
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 单位解析
|
||||
|
||||
使用 `parse_time_input` 解析时间参数:
|
||||
|
||||
```python
|
||||
from .utils.unit_parser import parse_time_input
|
||||
|
||||
# 支持格式: "5 min", "1 h", "300", "1.5 hours"
|
||||
time_seconds = parse_time_input("5 min") # -> 300.0
|
||||
time_seconds = parse_time_input(120) # -> 120.0
|
||||
time_seconds = parse_time_input("1 h") # -> 3600.0
|
||||
```
|
||||
|
||||
### 4. 参数验证
|
||||
|
||||
所有参数必须进行验证和类型转换:
|
||||
|
||||
```python
|
||||
# 验证范围
|
||||
if speed < 10.0 or speed > 1500.0:
|
||||
logger.warning(f"速度 {speed} 超出范围,修正为 300")
|
||||
speed = 300.0
|
||||
|
||||
# 类型转换
|
||||
param = float(param) if not isinstance(param, (int, float)) else param
|
||||
```
|
||||
|
||||
### 5. 日志记录
|
||||
|
||||
使用项目日志记录器:
|
||||
|
||||
```python
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def generate_protocol(...):
|
||||
logger.info(f"开始生成协议...")
|
||||
logger.debug(f"参数: vessel={vessel_id}, time={time}")
|
||||
logger.warning(f"参数修正: {old_value} -> {new_value}")
|
||||
```
|
||||
|
||||
## 便捷函数
|
||||
|
||||
为常用操作提供便捷函数:
|
||||
|
||||
```python
|
||||
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
speed: float = 300.0) -> List[Dict[str, Any]]:
|
||||
"""短时间搅拌(30秒)"""
|
||||
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
|
||||
|
||||
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: str = "5 min") -> List[Dict[str, Any]]:
|
||||
"""剧烈搅拌"""
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
|
||||
```
|
||||
|
||||
## 测试函数
|
||||
|
||||
每个协议文件应包含测试函数:
|
||||
|
||||
```python
|
||||
def test_{operation}_protocol():
|
||||
"""测试协议生成"""
|
||||
# 测试参数处理
|
||||
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
|
||||
vessel_id = extract_vessel_id(vessel_dict)
|
||||
assert vessel_id == "flask_1"
|
||||
|
||||
# 测试单位解析
|
||||
time_s = parse_time_input("5 min")
|
||||
assert time_s == 300.0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_{operation}_protocol()
|
||||
```
|
||||
|
||||
## 现有协议参考
|
||||
|
||||
- `stir_protocol.py` - 搅拌操作
|
||||
- `add_protocol.py` - 添加物料
|
||||
- `filter_protocol.py` - 过滤操作
|
||||
- `heatchill_protocol.py` - 加热/冷却
|
||||
- `separate_protocol.py` - 分离操作
|
||||
- `evaporate_protocol.py` - 蒸发操作
|
||||
319
.cursor/rules/registry-config.mdc
Normal file
319
.cursor/rules/registry-config.mdc
Normal file
@@ -0,0 +1,319 @@
|
||||
---
|
||||
description: 注册表配置规范 (YAML)
|
||||
globs: ["unilabos/registry/**/*.yaml"]
|
||||
---
|
||||
|
||||
# 注册表配置规范
|
||||
|
||||
## 概述
|
||||
|
||||
注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
unilabos/registry/
|
||||
├── devices/ # 设备类型注册
|
||||
│ ├── virtual_device.yaml
|
||||
│ ├── liquid_handler.yaml
|
||||
│ └── ...
|
||||
├── device_comms/ # 通信设备配置
|
||||
│ ├── communication_devices.yaml
|
||||
│ └── modbus_ioboard.yaml
|
||||
└── resources/ # 资源类型注册
|
||||
├── bioyond/
|
||||
├── organic/
|
||||
├── opentrons/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 设备注册表格式
|
||||
|
||||
### 基本结构
|
||||
|
||||
```yaml
|
||||
device_type_id:
|
||||
# 基本信息
|
||||
description: "设备描述"
|
||||
version: "1.0.0"
|
||||
category:
|
||||
- category_name
|
||||
icon: "icon_device.webp"
|
||||
|
||||
# 类配置
|
||||
class:
|
||||
module: "unilabos.devices.my_module:MyClass"
|
||||
type: python
|
||||
|
||||
# 状态类型(属性 -> ROS消息类型)
|
||||
status_types:
|
||||
status: String
|
||||
temperature: Float64
|
||||
is_running: Bool
|
||||
|
||||
# 动作映射
|
||||
action_value_mappings:
|
||||
action_name:
|
||||
type: UniLabJsonCommand # 或 UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema: {...}
|
||||
handles: {}
|
||||
```
|
||||
|
||||
### action_value_mappings 详细格式
|
||||
|
||||
```yaml
|
||||
action_value_mappings:
|
||||
# 同步动作
|
||||
my_sync_action:
|
||||
type: UniLabJsonCommand
|
||||
goal:
|
||||
param1: param1
|
||||
param2: param2
|
||||
feedback: {}
|
||||
result:
|
||||
success: success
|
||||
message: message
|
||||
goal_default:
|
||||
param1: 0.0
|
||||
param2: ""
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
device_param: unilabos_devices # 设备选择器
|
||||
resource_param: unilabos_resources # 资源选择器
|
||||
schema:
|
||||
title: "动作名称参数"
|
||||
description: "动作描述"
|
||||
type: object
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
properties:
|
||||
param1:
|
||||
type: number
|
||||
param2:
|
||||
type: string
|
||||
required:
|
||||
- param1
|
||||
feedback: {}
|
||||
result:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- goal
|
||||
|
||||
# 异步动作
|
||||
my_async_action:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback:
|
||||
progress: progress
|
||||
current_status: status
|
||||
result:
|
||||
success: success
|
||||
schema: {...}
|
||||
```
|
||||
|
||||
### 自动生成的动作
|
||||
|
||||
以 `auto-` 开头的动作由系统自动生成:
|
||||
|
||||
```yaml
|
||||
action_value_mappings:
|
||||
auto-initialize:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema: {...}
|
||||
|
||||
auto-cleanup:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema: {...}
|
||||
```
|
||||
|
||||
### handles 配置
|
||||
|
||||
用于工作流编辑器中的数据流连接:
|
||||
|
||||
```yaml
|
||||
handles:
|
||||
input:
|
||||
- handler_key: "input_resource"
|
||||
data_type: "resource"
|
||||
label: "输入资源"
|
||||
data_source: "handle"
|
||||
data_key: "resources"
|
||||
output:
|
||||
- handler_key: "output_labware"
|
||||
data_type: "resource"
|
||||
label: "输出器皿"
|
||||
data_source: "executor"
|
||||
data_key: "created_resource.@flatten"
|
||||
```
|
||||
|
||||
## 资源注册表格式
|
||||
|
||||
```yaml
|
||||
resource_type_id:
|
||||
description: "资源描述"
|
||||
version: "1.0.0"
|
||||
category:
|
||||
- category_name
|
||||
icon: ""
|
||||
handles: []
|
||||
init_param_schema: {}
|
||||
|
||||
class:
|
||||
module: "unilabos.resources.my_module:MyResource"
|
||||
type: pylabrobot # 或 python
|
||||
```
|
||||
|
||||
### PyLabRobot 资源示例
|
||||
|
||||
```yaml
|
||||
BIOYOND_Electrolyte_6VialCarrier:
|
||||
category:
|
||||
- bottle_carriers
|
||||
- bioyond
|
||||
class:
|
||||
module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier"
|
||||
type: pylabrobot
|
||||
version: "1.0.0"
|
||||
```
|
||||
|
||||
## 状态类型映射
|
||||
|
||||
Python 类型到 ROS 消息类型的映射:
|
||||
|
||||
| Python 类型 | ROS 消息类型 |
|
||||
|------------|-------------|
|
||||
| `str` | `String` |
|
||||
| `bool` | `Bool` |
|
||||
| `int` | `Int64` |
|
||||
| `float` | `Float64` |
|
||||
| `list` | `String` (序列化) |
|
||||
| `dict` | `String` (序列化) |
|
||||
|
||||
## 自动完善注册表
|
||||
|
||||
使用 `--complete_registry` 参数自动生成 schema:
|
||||
|
||||
```bash
|
||||
python -m unilabos.app.main --complete_registry
|
||||
```
|
||||
|
||||
这会:
|
||||
1. 扫描设备类的方法签名
|
||||
2. 自动生成 `auto-` 前缀的动作
|
||||
3. 生成 JSON Schema
|
||||
4. 更新 YAML 文件
|
||||
|
||||
## 验证规则
|
||||
|
||||
1. **device_type_id** 必须唯一
|
||||
2. **module** 路径必须正确可导入
|
||||
3. **status_types** 的类型必须是有效的 ROS 消息类型
|
||||
4. **schema** 必须是有效的 JSON Schema
|
||||
|
||||
## 示例:完整设备配置
|
||||
|
||||
```yaml
|
||||
virtual_stirrer:
|
||||
category:
|
||||
- virtual_device
|
||||
description: "虚拟搅拌器设备"
|
||||
version: "1.0.0"
|
||||
icon: "icon_stirrer.webp"
|
||||
handles: []
|
||||
init_param_schema: {}
|
||||
|
||||
class:
|
||||
module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer"
|
||||
type: python
|
||||
|
||||
status_types:
|
||||
status: String
|
||||
operation_mode: String
|
||||
current_speed: Float64
|
||||
is_stirring: Bool
|
||||
remaining_time: Float64
|
||||
|
||||
action_value_mappings:
|
||||
auto-initialize:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema:
|
||||
title: "initialize参数"
|
||||
type: object
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
properties: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
|
||||
stir:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal:
|
||||
stir_time: stir_time
|
||||
stir_speed: stir_speed
|
||||
settling_time: settling_time
|
||||
feedback:
|
||||
current_speed: current_speed
|
||||
remaining_time: remaining_time
|
||||
result:
|
||||
success: success
|
||||
goal_default:
|
||||
stir_time: 60.0
|
||||
stir_speed: 300.0
|
||||
settling_time: 30.0
|
||||
handles: {}
|
||||
schema:
|
||||
title: "stir参数"
|
||||
description: "搅拌操作"
|
||||
type: object
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
properties:
|
||||
stir_time:
|
||||
type: number
|
||||
description: "搅拌时间(秒)"
|
||||
stir_speed:
|
||||
type: number
|
||||
description: "搅拌速度(RPM)"
|
||||
settling_time:
|
||||
type: number
|
||||
description: "沉降时间(秒)"
|
||||
required:
|
||||
- stir_time
|
||||
- stir_speed
|
||||
feedback:
|
||||
type: object
|
||||
properties:
|
||||
current_speed:
|
||||
type: number
|
||||
remaining_time:
|
||||
type: number
|
||||
result:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
```
|
||||
233
.cursor/rules/ros-integration.mdc
Normal file
233
.cursor/rules/ros-integration.mdc
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
description: ROS 2 集成开发规范
|
||||
globs: ["unilabos/ros/**/*.py", "**/*_node.py"]
|
||||
---
|
||||
|
||||
# ROS 2 集成开发规范
|
||||
|
||||
## 概述
|
||||
|
||||
Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。
|
||||
|
||||
## 核心组件
|
||||
|
||||
### BaseROS2DeviceNode
|
||||
|
||||
设备节点基类,提供:
|
||||
- ROS Topic 自动发布(状态属性)
|
||||
- Action Server 自动创建(设备动作)
|
||||
- 资源管理服务
|
||||
- 异步任务调度
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
```
|
||||
|
||||
### 消息转换器
|
||||
|
||||
```python
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
convert_to_ros_msg,
|
||||
convert_from_ros_msg_with_mapping,
|
||||
msg_converter_manager,
|
||||
ros_action_to_json_schema,
|
||||
ros_message_to_json_schema,
|
||||
)
|
||||
```
|
||||
|
||||
## 设备与 ROS 集成
|
||||
|
||||
### post_init 方法
|
||||
|
||||
设备类必须实现 `post_init` 方法接收 ROS 节点:
|
||||
|
||||
```python
|
||||
class MyDevice:
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
"""ROS节点注入"""
|
||||
self._ros_node = ros_node
|
||||
```
|
||||
|
||||
### 状态属性发布
|
||||
|
||||
设备的 `@property` 属性会自动发布为 ROS Topic:
|
||||
|
||||
```python
|
||||
class MyDevice:
|
||||
@property
|
||||
def temperature(self) -> float:
|
||||
return self._temperature
|
||||
|
||||
# 自动发布到 /{namespace}/temperature Topic
|
||||
```
|
||||
|
||||
### Topic 配置装饰器
|
||||
|
||||
```python
|
||||
from unilabos.utils.decorator import topic_config
|
||||
|
||||
class MyDevice:
|
||||
@property
|
||||
@topic_config(period=1.0, print_publish=False, qos=10)
|
||||
def fast_data(self) -> float:
|
||||
"""高频数据 - 每秒发布一次"""
|
||||
return self._fast_data
|
||||
|
||||
@property
|
||||
@topic_config(period=5.0)
|
||||
def slow_data(self) -> str:
|
||||
"""低频数据 - 每5秒发布一次"""
|
||||
return self._slow_data
|
||||
```
|
||||
|
||||
### 订阅装饰器
|
||||
|
||||
```python
|
||||
from unilabos.utils.decorator import subscribe
|
||||
|
||||
class MyDevice:
|
||||
@subscribe(topic="/external/sensor_data", qos=10)
|
||||
def on_sensor_data(self, msg):
|
||||
"""订阅外部Topic"""
|
||||
self._sensor_value = msg.data
|
||||
```
|
||||
|
||||
## 异步操作
|
||||
|
||||
### 使用 ROS 节点睡眠
|
||||
|
||||
```python
|
||||
# 推荐:使用ROS节点的睡眠方法
|
||||
await self._ros_node.sleep(1.0)
|
||||
|
||||
# 不推荐:直接使用asyncio(可能导致回调阻塞)
|
||||
await asyncio.sleep(1.0)
|
||||
```
|
||||
|
||||
### 获取事件循环
|
||||
|
||||
```python
|
||||
from unilabos.ros.x.rclpyx import get_event_loop
|
||||
|
||||
loop = get_event_loop()
|
||||
```
|
||||
|
||||
## 消息类型
|
||||
|
||||
### unilabos_msgs 包
|
||||
|
||||
```python
|
||||
from unilabos_msgs.msg import Resource
|
||||
from unilabos_msgs.srv import (
|
||||
ResourceAdd,
|
||||
ResourceDelete,
|
||||
ResourceUpdate,
|
||||
ResourceList,
|
||||
SerialCommand,
|
||||
)
|
||||
from unilabos_msgs.action import SendCmd
|
||||
```
|
||||
|
||||
### Resource 消息结构
|
||||
|
||||
```python
|
||||
Resource:
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
type: str
|
||||
parent: str
|
||||
children: List[str]
|
||||
config: str # JSON字符串
|
||||
data: str # JSON字符串
|
||||
sample_id: str
|
||||
pose: Pose
|
||||
```
|
||||
|
||||
## 日志适配器
|
||||
|
||||
```python
|
||||
from unilabos.utils.log import info, debug, warning, error, trace
|
||||
|
||||
class MyDevice:
|
||||
def __init__(self):
|
||||
# 创建设备专属日志器
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
```
|
||||
|
||||
ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。
|
||||
|
||||
## Action Server
|
||||
|
||||
设备动作自动创建为 ROS Action Server:
|
||||
|
||||
```yaml
|
||||
# 在注册表中配置
|
||||
action_value_mappings:
|
||||
my_action:
|
||||
type: UniLabJsonCommandAsync # 异步Action
|
||||
goal: {...}
|
||||
feedback: {...}
|
||||
result: {...}
|
||||
```
|
||||
|
||||
### Action 类型
|
||||
|
||||
- **UniLabJsonCommand**: 同步动作
|
||||
- **UniLabJsonCommandAsync**: 异步动作(支持feedback)
|
||||
|
||||
## 服务客户端
|
||||
|
||||
```python
|
||||
from rclpy.client import Client
|
||||
|
||||
# 调用其他节点的服务
|
||||
response = await self._ros_node.call_service(
|
||||
service_name="/other_node/service",
|
||||
request=MyServiceRequest(...)
|
||||
)
|
||||
```
|
||||
|
||||
## 命名空间
|
||||
|
||||
设备节点使用命名空间隔离:
|
||||
|
||||
```
|
||||
/{device_id}/ # 设备命名空间
|
||||
/{device_id}/status # 状态Topic
|
||||
/{device_id}/temperature # 温度Topic
|
||||
/{device_id}/my_action # 动作Server
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
### 查看 Topic
|
||||
|
||||
```bash
|
||||
ros2 topic list
|
||||
ros2 topic echo /{device_id}/status
|
||||
```
|
||||
|
||||
### 查看 Action
|
||||
|
||||
```bash
|
||||
ros2 action list
|
||||
ros2 action info /{device_id}/my_action
|
||||
```
|
||||
|
||||
### 查看 Service
|
||||
|
||||
```bash
|
||||
ros2 service list
|
||||
ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **状态属性命名**: 使用蛇形命名法(snake_case)
|
||||
2. **Topic 频率**: 根据数据变化频率调整,避免过高频率
|
||||
3. **Action 反馈**: 长时间操作提供进度反馈
|
||||
4. **错误处理**: 使用 try-except 捕获并记录错误
|
||||
5. **资源清理**: 在 cleanup 方法中正确清理资源
|
||||
357
.cursor/rules/testing-patterns.mdc
Normal file
357
.cursor/rules/testing-patterns.mdc
Normal file
@@ -0,0 +1,357 @@
|
||||
---
|
||||
description: 测试开发规范
|
||||
globs: ["tests/**/*.py", "**/test_*.py"]
|
||||
---
|
||||
|
||||
# 测试开发规范
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── __init__.py
|
||||
├── devices/ # 设备测试
|
||||
│ └── liquid_handling/
|
||||
│ └── test_transfer_liquid.py
|
||||
├── resources/ # 资源测试
|
||||
│ ├── test_bottle_carrier.py
|
||||
│ └── test_resourcetreeset.py
|
||||
├── ros/ # ROS消息测试
|
||||
│ └── msgs/
|
||||
│ ├── test_basic.py
|
||||
│ ├── test_conversion.py
|
||||
│ └── test_mapping.py
|
||||
└── workflow/ # 工作流测试
|
||||
└── merge_workflow.py
|
||||
```
|
||||
|
||||
## 测试框架
|
||||
|
||||
使用 pytest 作为测试框架:
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pytest tests/
|
||||
|
||||
# 运行特定测试文件
|
||||
pytest tests/resources/test_bottle_carrier.py
|
||||
|
||||
# 运行特定测试函数
|
||||
pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier
|
||||
|
||||
# 显示详细输出
|
||||
pytest -v tests/
|
||||
|
||||
# 显示打印输出
|
||||
pytest -s tests/
|
||||
```
|
||||
|
||||
## 测试文件模板
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# 导入被测试的模块
|
||||
from unilabos.resources.bioyond.bottle_carriers import (
|
||||
BIOYOND_Electrolyte_6VialCarrier,
|
||||
)
|
||||
from unilabos.resources.bioyond.bottles import (
|
||||
BIOYOND_PolymerStation_Solid_Vial,
|
||||
)
|
||||
|
||||
|
||||
class TestBottleCarrier:
|
||||
"""BottleCarrier 测试类"""
|
||||
|
||||
def setup_method(self):
|
||||
"""每个测试方法前执行"""
|
||||
self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier")
|
||||
|
||||
def teardown_method(self):
|
||||
"""每个测试方法后执行"""
|
||||
pass
|
||||
|
||||
def test_carrier_creation(self):
|
||||
"""测试载架创建"""
|
||||
assert self.carrier.name == "test_carrier"
|
||||
assert len(self.carrier.sites) == 6
|
||||
|
||||
def test_bottle_placement(self):
|
||||
"""测试瓶子放置"""
|
||||
bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle")
|
||||
# 测试逻辑...
|
||||
assert bottle.name == "test_bottle"
|
||||
|
||||
|
||||
def test_standalone_function():
|
||||
"""独立测试函数"""
|
||||
result = some_function()
|
||||
assert result is True
|
||||
|
||||
|
||||
# 参数化测试
|
||||
@pytest.mark.parametrize("input,expected", [
|
||||
("5 min", 300.0),
|
||||
("1 h", 3600.0),
|
||||
("120", 120.0),
|
||||
(60, 60.0),
|
||||
])
|
||||
def test_time_parsing(input, expected):
|
||||
"""测试时间解析"""
|
||||
from unilabos.compile.utils.unit_parser import parse_time_input
|
||||
assert parse_time_input(input) == expected
|
||||
|
||||
|
||||
# 异常测试
|
||||
def test_invalid_input_raises_error():
|
||||
"""测试无效输入抛出异常"""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
invalid_function("bad_input")
|
||||
assert "invalid" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
# 跳过条件测试
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("ROS_DISTRO"),
|
||||
reason="需要ROS环境"
|
||||
)
|
||||
def test_ros_feature():
|
||||
"""需要ROS环境的测试"""
|
||||
pass
|
||||
```
|
||||
|
||||
## 设备测试
|
||||
|
||||
### 虚拟设备测试
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer
|
||||
|
||||
|
||||
class TestVirtualStirrer:
|
||||
"""VirtualStirrer 测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def stirrer(self):
|
||||
"""创建测试用搅拌器"""
|
||||
device = VirtualStirrer(
|
||||
device_id="test_stirrer",
|
||||
config={"max_speed": 1500.0, "min_speed": 50.0}
|
||||
)
|
||||
|
||||
# Mock ROS节点
|
||||
mock_node = MagicMock()
|
||||
mock_node.sleep = AsyncMock(return_value=None)
|
||||
device.post_init(mock_node)
|
||||
|
||||
return device
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, stirrer):
|
||||
"""测试初始化"""
|
||||
result = await stirrer.initialize()
|
||||
assert result is True
|
||||
assert stirrer.status == "待机中"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stir_action(self, stirrer):
|
||||
"""测试搅拌动作"""
|
||||
await stirrer.initialize()
|
||||
|
||||
result = await stirrer.stir(
|
||||
stir_time=5.0,
|
||||
stir_speed=300.0,
|
||||
settling_time=2.0
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert stirrer.operation_mode == "Completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stir_invalid_speed(self, stirrer):
|
||||
"""测试无效速度"""
|
||||
await stirrer.initialize()
|
||||
|
||||
# 速度超出范围
|
||||
result = await stirrer.stir(
|
||||
stir_time=5.0,
|
||||
stir_speed=2000.0, # 超过max_speed
|
||||
settling_time=0.0
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert "错误" in stirrer.status
|
||||
```
|
||||
|
||||
### 异步测试配置
|
||||
|
||||
```python
|
||||
# conftest.py
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""创建事件循环"""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
```
|
||||
|
||||
## 资源测试
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
)
|
||||
|
||||
|
||||
def test_resource_tree_creation():
|
||||
"""测试资源树创建"""
|
||||
tree_set = ResourceTreeSet()
|
||||
|
||||
# 添加资源
|
||||
resource = {"id": "res_1", "name": "Resource 1"}
|
||||
tree_set.add_resource(resource)
|
||||
|
||||
# 验证
|
||||
assert len(tree_set.all_nodes) == 1
|
||||
assert tree_set.get_resource("res_1") is not None
|
||||
|
||||
|
||||
def test_resource_tree_merge():
|
||||
"""测试资源树合并"""
|
||||
local_set = ResourceTreeSet()
|
||||
remote_set = ResourceTreeSet()
|
||||
|
||||
# 设置数据...
|
||||
|
||||
local_set.merge_remote_resources(remote_set)
|
||||
|
||||
# 验证合并结果...
|
||||
```
|
||||
|
||||
## ROS 消息测试
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
convert_to_ros_msg,
|
||||
convert_from_ros_msg_with_mapping,
|
||||
msg_converter_manager,
|
||||
)
|
||||
|
||||
|
||||
def test_message_conversion():
|
||||
"""测试消息转换"""
|
||||
# Python -> ROS
|
||||
python_data = {"id": "test", "value": 42}
|
||||
ros_msg = convert_to_ros_msg(python_data, MyMsgType)
|
||||
|
||||
assert ros_msg.id == "test"
|
||||
assert ros_msg.value == 42
|
||||
|
||||
# ROS -> Python
|
||||
result = convert_from_ros_msg_with_mapping(ros_msg, mapping)
|
||||
assert result["id"] == "test"
|
||||
```
|
||||
|
||||
## 协议测试
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import networkx as nx
|
||||
from unilabos.compile.stir_protocol import (
|
||||
generate_stir_protocol,
|
||||
extract_vessel_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def topology_graph():
|
||||
"""创建测试拓扑图"""
|
||||
G = nx.DiGraph()
|
||||
G.add_node("flask_1", **{"class": "flask"})
|
||||
G.add_node("stirrer_1", **{"class": "virtual_stirrer"})
|
||||
G.add_edge("stirrer_1", "flask_1")
|
||||
return G
|
||||
|
||||
|
||||
def test_generate_stir_protocol(topology_graph):
|
||||
"""测试搅拌协议生成"""
|
||||
actions = generate_stir_protocol(
|
||||
G=topology_graph,
|
||||
vessel="flask_1",
|
||||
time="5 min",
|
||||
stir_speed=300.0
|
||||
)
|
||||
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["device_id"] == "stirrer_1"
|
||||
assert actions[0]["action_name"] == "stir"
|
||||
|
||||
|
||||
def test_extract_vessel_id():
|
||||
"""测试vessel_id提取"""
|
||||
# 字典格式
|
||||
assert extract_vessel_id({"id": "flask_1"}) == "flask_1"
|
||||
|
||||
# 字符串格式
|
||||
assert extract_vessel_id("flask_2") == "flask_2"
|
||||
|
||||
# 空值
|
||||
assert extract_vessel_id("") == ""
|
||||
```
|
||||
|
||||
## 测试标记
|
||||
|
||||
```python
|
||||
# 慢速测试
|
||||
@pytest.mark.slow
|
||||
def test_long_running():
|
||||
pass
|
||||
|
||||
# 需要网络
|
||||
@pytest.mark.network
|
||||
def test_network_call():
|
||||
pass
|
||||
|
||||
# 需要ROS
|
||||
@pytest.mark.ros
|
||||
def test_ros_feature():
|
||||
pass
|
||||
```
|
||||
|
||||
运行特定标记的测试:
|
||||
|
||||
```bash
|
||||
pytest -m "not slow" # 排除慢速测试
|
||||
pytest -m ros # 仅ROS测试
|
||||
```
|
||||
|
||||
## 覆盖率
|
||||
|
||||
```bash
|
||||
# 生成覆盖率报告
|
||||
pytest --cov=unilabos tests/
|
||||
|
||||
# HTML报告
|
||||
pytest --cov=unilabos --cov-report=html tests/
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **测试命名**: `test_{功能}_{场景}_{预期结果}`
|
||||
2. **独立性**: 每个测试独立运行,不依赖其他测试
|
||||
3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务
|
||||
4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码
|
||||
5. **fixtures**: 使用 fixtures 共享测试设置
|
||||
6. **断言清晰**: 每个断言只验证一件事
|
||||
353
.cursor/rules/unilabos-project.mdc
Normal file
353
.cursor/rules/unilabos-project.mdc
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则
|
||||
globs: ["**/*.py", "**/*.yaml", "**/*.json"]
|
||||
---
|
||||
|
||||
# Uni-Lab-OS 项目开发规范
|
||||
|
||||
## 项目概述
|
||||
|
||||
Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Python 3.11** - 核心开发语言
|
||||
- **ROS 2** - 设备通信中间件 (rclpy)
|
||||
- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge)
|
||||
- **FastAPI** - Web API 服务
|
||||
- **WebSocket** - 实时通信
|
||||
- **NetworkX** - 拓扑图管理
|
||||
- **YAML** - 配置和注册表定义
|
||||
- **PyLabRobot** - 实验室自动化库集成
|
||||
- **pytest** - 测试框架
|
||||
- **asyncio** - 异步编程
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
unilabos/
|
||||
├── app/ # 应用入口、Web服务、后端
|
||||
├── compile/ # 协议编译器 (stir, add, filter 等)
|
||||
├── config/ # 配置管理
|
||||
├── devices/ # 设备驱动 (真实/虚拟)
|
||||
├── device_comms/ # 设备通信协议
|
||||
├── device_mesh/ # 3D网格和可视化
|
||||
├── registry/ # 设备和资源类型注册表 (YAML)
|
||||
├── resources/ # 资源定义
|
||||
├── ros/ # ROS 2 集成
|
||||
├── utils/ # 工具函数
|
||||
└── workflow/ # 工作流管理
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
### Python 风格
|
||||
|
||||
1. **类型注解**:所有函数必须使用类型注解
|
||||
```python
|
||||
def transfer_liquid(
|
||||
source: str,
|
||||
destination: str,
|
||||
volume: float,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
```
|
||||
|
||||
2. **Docstring**:使用 Google 风格的文档字符串
|
||||
```python
|
||||
def initialize(self) -> bool:
|
||||
"""
|
||||
初始化设备
|
||||
|
||||
Returns:
|
||||
bool: 初始化是否成功
|
||||
"""
|
||||
```
|
||||
|
||||
3. **导入顺序**:
|
||||
- 标准库
|
||||
- 第三方库
|
||||
- ROS 相关 (rclpy, unilabos_msgs)
|
||||
- 项目内部模块
|
||||
|
||||
### 异步编程
|
||||
|
||||
1. 设备操作方法使用 `async def`
|
||||
2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()`
|
||||
3. 长时间运行操作需提供进度反馈
|
||||
|
||||
```python
|
||||
async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool:
|
||||
"""执行搅拌操作"""
|
||||
start_time = time_module.time()
|
||||
while True:
|
||||
elapsed = time_module.time() - start_time
|
||||
remaining = max(0, stir_time - elapsed)
|
||||
|
||||
self.data.update({
|
||||
"remaining_time": remaining,
|
||||
"status": f"搅拌中: {stir_speed} RPM"
|
||||
})
|
||||
|
||||
if remaining <= 0:
|
||||
break
|
||||
await self._ros_node.sleep(1.0)
|
||||
return True
|
||||
```
|
||||
|
||||
### 日志规范
|
||||
|
||||
使用项目自定义日志系统:
|
||||
|
||||
```python
|
||||
from unilabos.utils.log import logger, info, debug, warning, error, trace
|
||||
|
||||
# 在设备类中使用
|
||||
self.logger = logging.getLogger(f"DeviceName.{self.device_id}")
|
||||
self.logger.info("设备初始化完成")
|
||||
```
|
||||
|
||||
## 设备驱动开发
|
||||
|
||||
### 设备类结构
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
class MyDevice:
|
||||
"""设备驱动类"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
|
||||
self.device_id = device_id or "unknown_device"
|
||||
self.config = config or {}
|
||||
self.data = {} # 设备状态数据
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
"""ROS节点注入"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""初始化设备"""
|
||||
pass
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
"""清理设备"""
|
||||
pass
|
||||
|
||||
# 状态属性 - 自动发布为 ROS Topic
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "待机")
|
||||
```
|
||||
|
||||
### 状态属性装饰器
|
||||
|
||||
```python
|
||||
from unilabos.utils.decorator import topic_config
|
||||
|
||||
class MyDevice:
|
||||
@property
|
||||
@topic_config(period=1.0, qos=10) # 每秒发布一次
|
||||
def temperature(self) -> float:
|
||||
return self._temperature
|
||||
```
|
||||
|
||||
### 虚拟设备
|
||||
|
||||
虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py`
|
||||
|
||||
## 注册表配置
|
||||
|
||||
### 设备注册表 (YAML)
|
||||
|
||||
位置: `unilabos/registry/devices/*.yaml`
|
||||
|
||||
```yaml
|
||||
my_device_type:
|
||||
category:
|
||||
- my_category
|
||||
description: "设备描述"
|
||||
version: "1.0.0"
|
||||
class:
|
||||
module: "unilabos.devices.my_device:MyDevice"
|
||||
type: python
|
||||
status_types:
|
||||
status: String
|
||||
temperature: Float64
|
||||
action_value_mappings:
|
||||
auto-initialize:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema: {...}
|
||||
```
|
||||
|
||||
### 资源注册表 (YAML)
|
||||
|
||||
位置: `unilabos/registry/resources/**/*.yaml`
|
||||
|
||||
```yaml
|
||||
my_container:
|
||||
category:
|
||||
- container
|
||||
class:
|
||||
module: "unilabos.resources.my_resource:MyContainer"
|
||||
type: pylabrobot
|
||||
version: "1.0.0"
|
||||
```
|
||||
|
||||
## 协议编译器
|
||||
|
||||
位置: `unilabos/compile/*_protocol.py`
|
||||
|
||||
### 协议生成函数模板
|
||||
|
||||
```python
|
||||
from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
|
||||
def generate_my_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: Union[str, dict],
|
||||
param1: float = 0.0,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成操作协议序列
|
||||
|
||||
Args:
|
||||
G: 物理拓扑图
|
||||
vessel: 容器ID或字典
|
||||
param1: 参数1
|
||||
|
||||
Returns:
|
||||
List[Dict]: 动作序列
|
||||
"""
|
||||
# 提取vessel_id
|
||||
vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "")
|
||||
|
||||
# 查找设备
|
||||
device_id = find_connected_device(G, vessel_id)
|
||||
|
||||
# 生成动作
|
||||
action_sequence = [{
|
||||
"device_id": device_id,
|
||||
"action_name": "my_action",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id},
|
||||
"param1": float(param1)
|
||||
}
|
||||
}]
|
||||
|
||||
return action_sequence
|
||||
```
|
||||
|
||||
## 测试规范
|
||||
|
||||
### 测试文件位置
|
||||
|
||||
- 单元测试: `tests/` 目录
|
||||
- 设备测试: `tests/devices/`
|
||||
- 资源测试: `tests/resources/`
|
||||
- ROS消息测试: `tests/ros/msgs/`
|
||||
|
||||
### 测试命名
|
||||
|
||||
```python
|
||||
# tests/devices/my_device/test_my_device.py
|
||||
|
||||
import pytest
|
||||
|
||||
def test_device_initialization():
|
||||
"""测试设备初始化"""
|
||||
pass
|
||||
|
||||
def test_device_action():
|
||||
"""测试设备动作"""
|
||||
pass
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
```python
|
||||
from unilabos.utils.exception import UniLabException
|
||||
|
||||
try:
|
||||
result = await device.execute_action()
|
||||
except ValueError as e:
|
||||
self.logger.error(f"参数错误: {e}")
|
||||
self.data["status"] = "错误: 参数无效"
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"执行失败: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
## 配置管理
|
||||
|
||||
```python
|
||||
from unilabos.config.config import BasicConfig, HTTPConfig
|
||||
|
||||
# 读取配置
|
||||
port = BasicConfig.port
|
||||
is_host = BasicConfig.is_host_mode
|
||||
|
||||
# 配置文件: local_config.py
|
||||
```
|
||||
|
||||
## 常用工具
|
||||
|
||||
### 单例模式
|
||||
|
||||
```python
|
||||
from unilabos.utils.decorator import singleton
|
||||
|
||||
@singleton
|
||||
class MyManager:
|
||||
pass
|
||||
```
|
||||
|
||||
### 类型检查
|
||||
|
||||
```python
|
||||
from unilabos.utils.type_check import NoAliasDumper
|
||||
|
||||
yaml.dump(data, f, Dumper=NoAliasDumper)
|
||||
```
|
||||
|
||||
### 导入管理
|
||||
|
||||
```python
|
||||
from unilabos.utils.import_manager import get_class
|
||||
|
||||
device_class = get_class("unilabos.devices.my_device:MyDevice")
|
||||
```
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
提交信息格式:
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
```
|
||||
|
||||
类型:
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复bug
|
||||
- `docs`: 文档更新
|
||||
- `refactor`: 重构
|
||||
- `test`: 测试相关
|
||||
- `chore`: 构建/工具相关
|
||||
|
||||
示例:
|
||||
```
|
||||
feat(devices): 添加虚拟搅拌器设备
|
||||
|
||||
- 实现VirtualStirrer类
|
||||
- 支持定时搅拌和持续搅拌模式
|
||||
- 添加速度验证逻辑
|
||||
```
|
||||
@@ -1,483 +0,0 @@
|
||||
---
|
||||
name: yibin-electrolyte-submit
|
||||
description: >-
|
||||
通过 Uni-Lab Notebook API 向宜宾电解液工站提交实验,覆盖配液分液(Bioyond LIMS)、
|
||||
扣电组装(CoinCellAssembly)、扣电测试全流程。
|
||||
包含 Excel 解析、formulation 构建、工作流节点参数填写、notebook 提交与状态轮询。
|
||||
Use when the user wants to submit electrolyte experiments, assemble or test coin cells,
|
||||
parse experiment Excel files, build notebook payloads, or mentions
|
||||
宜宾/配液/分液/扣电/电解液实验/notebook提交/CoinCell/BioyondLIMS.
|
||||
---
|
||||
|
||||
# 宜宾电解液产线 API 操作指南
|
||||
|
||||
本 skill 覆盖两个设备的完整操作流程:
|
||||
1. **配液分液工站** (`bioyond_cell_workstation`) — Bioyond LIMS 配液/分液/转运
|
||||
2. **扣电组装站** (`BatteryStation`) — Modbus PLC 扣电组装/数据采集
|
||||
|
||||
## 设备信息
|
||||
|
||||
| 属性 | 配液分液工站 | 扣电组装站 |
|
||||
|------|------------|-----------|
|
||||
| device_id | `bioyond_cell_workstation` | `BatteryStation` |
|
||||
| 显示名 | 配液分液工站 | 扣电工作站 |
|
||||
| 源码 | `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` |
|
||||
| 类名 | `BioyondCellWorkstation` | `CoinCellAssemblyWorkstation` |
|
||||
| 通讯 | HTTP REST (Bioyond LIMS API) | Modbus TCP (PLC 寄存器) |
|
||||
|
||||
## 前置条件
|
||||
|
||||
### 认证信息
|
||||
|
||||
```
|
||||
AUTH="Authorization: Lab OTdlY2FkNmUtZmZmMi00YjhiLThhOWEtNWM5ODAyOTJmOTUxOmU0OGM2YWJkLTA4ZmEtNDFjMy04NzhhLTc4M2FiODlhZjYxMw=="
|
||||
BASE="https://uni-lab.test.bohrium.com"
|
||||
```
|
||||
|
||||
来源:`--ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --addr test`
|
||||
|
||||
### 启动 unilab(云端模式)
|
||||
|
||||
> **重要**:提交实验前必须确保 unilab 正在运行且已连接云端 WebSocket。
|
||||
|
||||
```powershell
|
||||
$env:PYTHONIOENCODING="utf-8"
|
||||
conda activate newunilab2603
|
||||
cd D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation
|
||||
unilab -g D:\UniLabdev\Uni-Lab-OS\yibin_electrolyte_config.json --ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --upload_registry --addr test --disable_browser --skip_env_check
|
||||
```
|
||||
|
||||
**启动要点**:
|
||||
1. 必须先激活虚拟环境 `newunilab2603`
|
||||
2. 工作目录切到 `unilabos/devices/workstation`(设备驱动所在目录)
|
||||
3. `--upload_registry` 将 64 个设备 + 142 个资源注册到云端
|
||||
4. `--skip_env_check` + `PYTHONIOENCODING=utf-8` 避免 Windows GBK 编码崩溃
|
||||
5. 启动后后台运行,等待日志出现 `Application startup complete` 和 `Host node ready signal published with 3 devices`
|
||||
|
||||
**验证连接成功的标志**:
|
||||
- 日志出现 `[MessageProcessor] ... wss://uni-lab.test.bohrium.com/api/v1/ws/schedule`
|
||||
- 日志出现 `[WebSocketClient] Host node ready signal published with 3 devices`
|
||||
- 日志出现 `Resource tree add completed`(资源树同步完成)
|
||||
|
||||
### 云端物料上架与入库(启动后必做)
|
||||
|
||||
> **在提交实验之前,必须提醒用户完成以下云端操作,否则实验会因物料缺失而失败。**
|
||||
|
||||
1. **拖拽上料**:在云端 UI(`$BASE/laboratory/<lab_uuid>`)的资源树视图中,将物料拖拽到对应的仓库/库位上。unilab 启动后资源树会自动同步到云端,但物料的**上架位置**需要用户在 UI 上手动确认或调整。
|
||||
|
||||
2. **确认配液物料入库**:确保所有配液实验需要的试剂(如 LiPF6、EC、DMC、EMC 等)已在 LIMS 系统中完成入库。可通过以下方式验证:
|
||||
- 云端 UI 资源树中对应仓库(如"粉末加样头堆栈"、"配液站内试剂仓库")下有物料节点
|
||||
- 或通过 API #8 获取资源树后检查物料节点是否存在
|
||||
|
||||
3. **告知 AI 可以提交**:用户完成上述操作后,告知 AI "物料已上架,可以提交实验",AI 再执行 notebook 提交流程。
|
||||
|
||||
**提醒话术模板**(AI 应在启动成功后发送给用户):
|
||||
```
|
||||
unilab 已成功启动并连接云端。提交实验前请完成以下操作:
|
||||
1. 在云端 UI 上确认资源树中的物料位置,必要时拖拽调整上料位
|
||||
2. 确保配液所需的试剂(粉末、液体)已在 LIMS 中完成入库
|
||||
3. 完成后告诉我,我将为您提交实验
|
||||
```
|
||||
|
||||
### 生成 Action Schema(首次使用)
|
||||
|
||||
启动 unilab 后,在 `unilabos_data/` 目录下会生成 `req_device_registry_upload.json`。运行以下命令提取两个设备的 action JSON:
|
||||
|
||||
```bash
|
||||
python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json bioyond_cell_workstation .cursor/skills/yibin-electrolyte-submit/actions/
|
||||
python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json BatteryStation .cursor/skills/yibin-electrolyte-submit/actions/
|
||||
```
|
||||
|
||||
## 请求约定
|
||||
|
||||
- Windows 平台**必须用 `curl.exe`**(非 PowerShell 的 curl 别名)
|
||||
- 所有请求带 `$AUTH` 头
|
||||
- URL 格式:`$BASE/api/v1/<endpoint>`
|
||||
- POST/PATCH 请求体写入临时 JSON 文件后用 `-d '@tmp.json'` 传参(避免 PowerShell 转义问题)
|
||||
- 本地 API 基址:`http://127.0.0.1:8002/api/v1/`
|
||||
|
||||
## Session State
|
||||
|
||||
每次会话开始时,依次获取以下信息:
|
||||
|
||||
```bash
|
||||
# 1. lab_uuid
|
||||
curl.exe -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
# → data.uuid → $lab_uuid
|
||||
|
||||
# 2. project_uuid
|
||||
curl.exe -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
# → data.items[].uuid/name → 让用户选择或取唯一项 → $project_uuid
|
||||
```
|
||||
|
||||
## 工作流模板(重要)
|
||||
|
||||
> **必须向用户索要已有的工作流模板 UUID 或 URL,不要自行创建。**
|
||||
>
|
||||
> 原因:通过 `edge/workflow/node` API 创建节点会报 `resource_node_template not found`——
|
||||
> 云端的工作流节点模板系统和设备注册表是独立的,需要用户在云端 UI 上预先配置好工作流模板。
|
||||
|
||||
**获取方式**:
|
||||
- 用户提供工作流页面 URL,如 `$BASE/laboratory/<lab_uuid>/workflow/<workflow_uuid>`
|
||||
- 从 URL 中提取 `workflow_uuid`
|
||||
- 用 API 获取模板详情:
|
||||
|
||||
```
|
||||
GET /api/v1/lab/workflow/template/detail/<workflow_uuid>
|
||||
```
|
||||
|
||||
返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles、disabled。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
工作流 URL: https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/b49f80d9-58d6-4456-a521-56f4dd39cda0
|
||||
→ workflow_uuid = b49f80d9-58d6-4456-a521-56f4dd39cda0
|
||||
```
|
||||
|
||||
从模板详情中提取**未 disabled** 的节点的 `uuid` 和 `name`,后续提交 notebook 时使用。
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### #1 获取 lab_uuid
|
||||
|
||||
```
|
||||
GET /api/v1/edge/lab/info
|
||||
```
|
||||
|
||||
### #2 列出项目
|
||||
|
||||
```
|
||||
GET /api/v1/lab/project/list?lab_uuid=$lab_uuid
|
||||
```
|
||||
|
||||
返回 `data.items[]`,取 `uuid` 和 `name`。
|
||||
|
||||
### #3 获取工作流模板详情
|
||||
|
||||
```
|
||||
GET /api/v1/lab/workflow/template/detail/<workflow_uuid>
|
||||
```
|
||||
|
||||
返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles。
|
||||
提取活跃节点(`disabled != true`)的 `uuid` 用于构建 notebook 请求。
|
||||
|
||||
### #4 提交实验(创建 notebook)— 核心 API
|
||||
|
||||
```
|
||||
POST /api/v1/lab/notebook
|
||||
Body: {
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"project_uuid": "<project_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
{
|
||||
"sample_uuids": [],
|
||||
"datas": [
|
||||
{
|
||||
"node_uuid": "<模板中的节点UUID>",
|
||||
"param": { <参数键值对> },
|
||||
"sample_params": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键注意事项**:
|
||||
- `node_params` 是数组,每个元素代表一轮实验
|
||||
- `datas` 中每个节点对应模板中的一个活跃节点
|
||||
- `param` 中的字段名**必须使用 Python 函数参数名**,不能用模板中存储的 LIMS 字段名(见下方映射表)
|
||||
|
||||
### #5 查询 notebook 状态
|
||||
|
||||
```
|
||||
GET /api/v1/lab/notebook/status?uuid=<notebook_uuid>
|
||||
```
|
||||
|
||||
| status | 含义 |
|
||||
|--------|------|
|
||||
| `running` | 执行中 |
|
||||
| `success` | 成功 |
|
||||
| `fail` | 失败 |
|
||||
|
||||
### #6 运行设备单动作(本地 API)
|
||||
|
||||
```
|
||||
POST http://127.0.0.1:8002/api/v1/job/add
|
||||
Body: {
|
||||
"device_id": "<device_id>",
|
||||
"action": "<action_name>",
|
||||
"action_args": { <参数键值对> },
|
||||
"sample_material": {}
|
||||
}
|
||||
```
|
||||
|
||||
本地 API 可自动解析 `action_type`,无需手动指定。适用于快速调试或云端未连接时。
|
||||
|
||||
### #7 查询本地任务状态
|
||||
|
||||
```
|
||||
GET http://127.0.0.1:8002/api/v1/job/<job_id>/status
|
||||
```
|
||||
|
||||
| status | 含义 |
|
||||
|--------|------|
|
||||
| 0 | UNKNOWN |
|
||||
| 1 | ACCEPTED |
|
||||
| 2 | EXECUTING |
|
||||
| 4 | SUCCEEDED |
|
||||
| 5 | CANCELED |
|
||||
| 6 | ABORTED |
|
||||
|
||||
### #8 获取资源树
|
||||
|
||||
```
|
||||
GET /api/v1/lab/material/download/<lab_uuid>
|
||||
```
|
||||
|
||||
返回所有节点(`id`, `name`, `uuid`, `type`, `parent`)。填写 Slot 字段时用此接口筛选节点。
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
action JSON 中 `placeholder_keys` 标记了哪些字段需要填 Slot:
|
||||
|
||||
| placeholder 值 | Slot 类型 | 填写格式 |
|
||||
|---------------|-----------|---------|
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` 路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` 路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` 字符串 |
|
||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` |
|
||||
|
||||
### ResourceSlot 填写
|
||||
|
||||
从 API #8 资源树中筛选**物料**节点:
|
||||
|
||||
```json
|
||||
{"id": "/bioyond_cell_workstation/YB_Bioyond_Deck/自动堆栈-左", "name": "自动堆栈-左", "uuid": "3a19debc-..."}
|
||||
```
|
||||
|
||||
数组字段:`[{id, name, uuid}, ...]`
|
||||
特例:`create_resource` 的 `res_id` 允许填不存在的路径。
|
||||
|
||||
### DeviceSlot 填写
|
||||
|
||||
从资源树筛选 `type=device` 的节点,填路径字符串:
|
||||
|
||||
```
|
||||
"/BatteryStation"
|
||||
"/bioyond_cell_workstation"
|
||||
```
|
||||
|
||||
### FormulationSlot 填写
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sample_uuid": "",
|
||||
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
||||
"liquids": [
|
||||
{ "name": "LiPF6", "mass": 12.5 },
|
||||
{ "name": "EC", "mass": 50.0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`well_name` 从资源树中取物料节点的 `name`。
|
||||
|
||||
## 参数名映射(重要的坑)
|
||||
|
||||
> 工作流模板中存储的参数名和 Python 函数实际接受的参数名**不一定相同**。
|
||||
> 提交 notebook 时必须使用 **Python 函数参数名**。
|
||||
|
||||
### `create_orders_formulation` 参数映射
|
||||
|
||||
| 模板中的 param 键 | 实际 Python 参数名 | 说明 |
|
||||
|-------------------|-------------------|------|
|
||||
| `pouch_cell_info` | `pouch_cell_volume` | 软包组装分液体积 (mL) |
|
||||
| `conductivity_info` | `conductivity_volume` | 电导测试分液体积 (mL) |
|
||||
| `load_shedding_info` | `coin_cell_volume` | 扣电组装分液体积 (mL) |
|
||||
| `formulation` | `formulation` | 配方数组(名称一致) |
|
||||
| `batch_id` | `batch_id` | 批次号(名称一致) |
|
||||
| `bottle_type` | `bottle_type` | 配液瓶类型(名称一致) |
|
||||
| `mix_time` | `mix_time` | 混匀时间(秒)(名称一致) |
|
||||
| `conductivity_bottle_count` | `conductivity_bottle_count` | 电导瓶数(名称一致) |
|
||||
|
||||
当从模板中读到 `param` 包含 `pouch_cell_info` 等 LIMS 字段名时,提交 notebook 时要用右列的 Python 函数参数名。否则会报 `TypeError: got an unexpected keyword argument`。
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 方式一:通过 Notebook API 批量提交(推荐)
|
||||
|
||||
**适用场景**:多组配方的批量实验,云端管理实验记录
|
||||
|
||||
```
|
||||
1. 向用户索要工作流模板 URL(不要自行创建)
|
||||
2. 获取 lab_uuid(API #1)和 project_uuid(API #2)
|
||||
3. 获取工作流模板详情(API #3),提取活跃节点 UUID
|
||||
4. 解析用户提供的 Excel 文件,构建 formulation 数组
|
||||
5. 提交 notebook(API #4)
|
||||
6. 轮询 notebook 状态(API #5)直到完成
|
||||
```
|
||||
|
||||
**Excel 解析规则**:
|
||||
- 全局参数在第一个数据行:`batch_id`、`bottle_type`、`mix_time`、`coin_cell_volume`、`pouch_cell_volume`、`conductivity_volume`、`conductivity_bottle_count`
|
||||
- 配方列从"试剂名1"开始,交替排列:试剂名列 + 质量列(以 `(g)` 结尾)
|
||||
- 每行一个配方,`order_name` = 配方ID列
|
||||
- formulation 中每个配方的 materials 数组只包含 `mass > 0` 的试剂
|
||||
|
||||
**node_params 构建**:所有配方放入同一个 round 的同一个 datas 条目中,因为只有一个节点(`create_orders_formulation`)。
|
||||
|
||||
### 方式二:设备单步操作(本地 API)
|
||||
|
||||
**适用场景**:调试、快速测试
|
||||
|
||||
```
|
||||
1. 确保 unilab 已在本地启动
|
||||
2. 通过 POST http://127.0.0.1:8002/api/v1/job/add 提交任务
|
||||
3. 通过 GET /api/v1/job/<job_id>/status 查询状态
|
||||
```
|
||||
|
||||
### 设备操作流程:配液 → 转运 → 扣电
|
||||
|
||||
```
|
||||
1. [配液站] scheduler_start_and_auto_feeding → 启动调度 + 上料
|
||||
2. [配液站] create_orders_formulation → 创建配液实验(配方输入)
|
||||
3. [配液站] transfer_3_to_2_to_1_auto → 分液瓶板转运到扣电站
|
||||
4. [扣电站] func_pack_device_init_auto_start_combined → 初始化+自动+启动
|
||||
5. [扣电站] func_sendbottle_allpack_multi → 发送瓶数+批量组装
|
||||
```
|
||||
|
||||
## 云端使用心得
|
||||
|
||||
### 环境准备
|
||||
- Windows 必须设置 `$env:PYTHONIOENCODING="utf-8"` 防止编码崩溃
|
||||
- 使用 `--skip_env_check` 跳过依赖检查,加快启动
|
||||
- 工作目录建议在 `unilabos/devices/workstation` 下启动
|
||||
|
||||
### 连接与注册
|
||||
- `--upload_registry` 会自动将设备和资源注册到云端
|
||||
- WebSocket 连接建立后,本地和云端的资源树会自动同步
|
||||
- 注册成功后用户需在云端 UI 完成**物料拖放上架**操作
|
||||
- 如果 unilab 断开重连,资源树会重新同步
|
||||
|
||||
### 工作流模板
|
||||
- **不要自行调用 API 创建工作流或节点**——云端工作流节点模板需要预配置
|
||||
- 始终向用户索要已有的工作流模板 URL
|
||||
- 从 URL 中提取 `workflow_uuid`,通过 API #3 获取详情
|
||||
- 模板中 `disabled: true` 的节点跳过,只处理活跃节点
|
||||
|
||||
### Notebook 实验提交
|
||||
- Notebook 是云端管理实验的标准方式
|
||||
- 一个 notebook 可包含多轮(`node_params` 数组),每轮可包含多组参数
|
||||
- 提交后通过 API #5 轮询状态,LIMS 配液流程通常需要较长时间(8 个配方约 30-60 分钟)
|
||||
- 实验进度可在云端 UI 和本地 unilab 日志中同步查看
|
||||
|
||||
### 常见错误
|
||||
| 错误 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| `edge not started error` | unilab 未连接云端 WebSocket | 检查 unilab 是否在运行、重启 |
|
||||
| `resource_node_template not found` | 云端没有该设备的工作流模板 | 向用户索要已有模板,不要自行创建 |
|
||||
| `got an unexpected keyword argument` | 参数名用了模板字段名而非 Python 函数参数名 | 参照上方映射表转换 |
|
||||
| `UnicodeEncodeError: 'gbk'` | Windows 默认编码不支持特殊字符 | 设置 `PYTHONIOENCODING=utf-8` |
|
||||
| `parse parameter error` | 云端 API 字段名错误 | `device_id` (非 `device_name`)、`action` (非 `action_name`)、必须带 `action_type` |
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
1. 先读本文件了解 API 端点、参数映射和云端注意事项
|
||||
2. 需要具体 action 参数时,读 [action-index.md](action-index.md) 查找 action 名称和核心参数
|
||||
3. 需要完整 schema 时,读 `actions/<action_name>.json`(需先运行提取命令生成)
|
||||
4. 需要理解参数含义时,读设备源码
|
||||
|
||||
## 完整 Notebook 提交 Checklist
|
||||
|
||||
```
|
||||
- [ ] 确认 unilab 已在本地启动并连接云端 WebSocket
|
||||
- [ ] 提醒用户在云端 UI 拖拽上料、确认物料位置
|
||||
- [ ] 提醒用户确认配液所需试剂已在 LIMS 完成入库
|
||||
- [ ] 等待用户确认物料就绪后再继续
|
||||
- [ ] 向用户索要工作流模板 URL → 提取 workflow_uuid
|
||||
- [ ] 获取 lab_uuid(API #1)
|
||||
- [ ] 获取 project_uuid(API #2)
|
||||
- [ ] 获取工作流模板详情(API #3),提取活跃节点 UUID
|
||||
- [ ] 解析用户 Excel 文件 → 构建 formulation + 全局参数
|
||||
- [ ] 注意参数名映射(模板字段名 → Python 函数参数名)
|
||||
- [ ] 提交 notebook(API #4)
|
||||
- [ ] 轮询 notebook 状态(API #5)直到完成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 真实场景:宜宾产线 Excel 提交提示词模板
|
||||
|
||||
> 以下为已验证可用的标准提示词,适用于配液-分液-扣电全流程。
|
||||
|
||||
### 场景说明
|
||||
|
||||
- unilab 运行在本地 Windows 机器(miniforge 环境),连接云端 WebSocket
|
||||
- AI(Cursor / OpenClaw)在任意设备上,通过云端 API 操作,**不需要本地 127.0.0.1**
|
||||
- 工作流为 5 节点串联:`create_orders_formulation` → `transfer_3_to_2_to_1_auto` → `func_pack_device_init_auto_start_combined` → `func_sendbottle_allpack_multi` → `transfer_1_to_2`
|
||||
|
||||
### 已知固定参数(宜宾产线)
|
||||
|
||||
```
|
||||
BASE = https://uni-lab.test.bohrium.com
|
||||
lab_uuid = e9ed9102-d709-4741-b7a0-d1e8578e2065
|
||||
project = YiBinElectrolyte (bc5224b4-8120-4765-9961-9dfc1802a1f6)
|
||||
workflow = 配液分液formulation全流程 (2bc59938-db79-4415-ac2d-9897ef125f2f)
|
||||
```
|
||||
|
||||
#### 工作流节点 UUID(固定,无需重新查询)
|
||||
|
||||
| 顺序 | action | node_uuid |
|
||||
|------|--------|-----------|
|
||||
| Step1 | auto-create_orders_formulation | `ece6744a-81ac-4ae4-8cd1-1c8eeda1dab6` |
|
||||
| Step2 | auto-transfer_3_to_2_to_1_auto | `1c37a8dd-5ba0-413d-81db-94b9c936a171` |
|
||||
| Step3 | auto-func_pack_device_init_auto_start_combined | `97a676a2-d257-4479-9096-073b40300970` |
|
||||
| Step4 | auto-func_sendbottle_allpack_multi | `cf69017a-d29c-4aad-a63b-309d63dac2e9` |
|
||||
| Step5 | auto-transfer_1_to_2 | `80d1c1aa-dbc3-4601-86b7-5c22a992dd9e` |
|
||||
|
||||
### 标准提示词
|
||||
|
||||
```
|
||||
请使用 yibin-electrolyte-submit skill,提交以下实验:
|
||||
|
||||
工作流模板 URL:https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/2bc59938-db79-4415-ac2d-9897ef125f2f
|
||||
Excel 文件路径:<粘贴或上传 xlsx 路径>
|
||||
|
||||
注意事项:
|
||||
- lab_uuid、project_uuid、workflow节点UUID均已固定,无需重新查询
|
||||
- 直接解析 Excel → 构建 payload → 提交
|
||||
- mix_time 传标量整数即可(已兼容)
|
||||
- 试剂名以 Excel 为准,注意区分 LiDFOB / LiDOFB 等拼写
|
||||
- csv_export_path 取 Excel 中 csv_export_path 列的值
|
||||
- 提交后告知 notebook UUID,无需自动轮询(实验耗时较长)
|
||||
```
|
||||
|
||||
### Excel 列结构说明(experment_template_0415sim-*.xlsx)
|
||||
|
||||
| 列范围 | 内容 |
|
||||
|--------|------|
|
||||
| C | batch_id |
|
||||
| D | bottle_type |
|
||||
| E-H | coin_cell_volume / conductivity_bottle_count / conductivity_volume / csv_export_path |
|
||||
| I-T | 试剂名+质量 交替排列(最多6对)|
|
||||
| U | mix_time |
|
||||
| V | order_name(每行配方的订单号)|
|
||||
| W | pouch_cell_volume |
|
||||
| X-Y | target_device / target_location(Step2参数)|
|
||||
| AA | material_search_enable(Step3参数)|
|
||||
| AB-AS | 扣电站参数(Step4)|
|
||||
|
||||
### CSV 导出说明
|
||||
|
||||
每次 `create_orders_formulation` 完成后,在 `csv_export_path` 目录下生成:
|
||||
```
|
||||
electrolyte_orders_<YYYYMMDD_HHMMSS>.csv
|
||||
```
|
||||
列:`orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间`
|
||||
|
||||
> **注意**:barCode 为 `null` 或 `"nullBarCode123456"` 是正常现象,表示 LIMS 中该物料尚未扫码。配液瓶缺失通常是因为物料未放在手动传递窗(`locationId` 前缀 `3a19deae-2c7a-`)。
|
||||
@@ -1,295 +0,0 @@
|
||||
# Action 索引
|
||||
|
||||
> Action JSON 文件需运行提取命令生成,详见 [SKILL.md](SKILL.md) 中「生成 Action Schema」。
|
||||
> 以下描述和参数信息基于源码分析。
|
||||
|
||||
---
|
||||
|
||||
## 配液分液工站 (`bioyond_cell_workstation`)
|
||||
|
||||
源码:`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
### 调度控制
|
||||
|
||||
#### `scheduler_start`
|
||||
|
||||
启动 Bioyond LIMS 调度系统
|
||||
|
||||
- **核心参数**: 无(仅需 apiKey/requestTime,由设备内部处理)
|
||||
- **返回**: LIMS 响应 `{code, message, data}`
|
||||
|
||||
#### `scheduler_stop`
|
||||
|
||||
停止调度
|
||||
|
||||
- **核心参数**: 无
|
||||
|
||||
#### `scheduler_continue`
|
||||
|
||||
继续调度(从暂停状态恢复)
|
||||
|
||||
- **核心参数**: 无
|
||||
|
||||
#### `scheduler_reset`
|
||||
|
||||
复位调度
|
||||
|
||||
- **核心参数**: 无
|
||||
|
||||
#### `scheduler_start_and_auto_feeding`
|
||||
|
||||
**组合操作**:启动调度 + 自动化上料(4号→3号手套箱)
|
||||
|
||||
- **核心参数**: `xlsx_path`(Excel 物料模板路径,可选)
|
||||
- **可选参数**: WH4 加样头面 12 个点位(materialName + quantity)、WH4 原液瓶面 9 个点位(materialName + quantity + materialType + targetWH)、WH3 人工堆栈 15 个点位(materialType + materialId + quantity)
|
||||
- **流程**: 先 `scheduler_start()`,成功后执行 `auto_feeding4to3()`
|
||||
- **备注**: 支持 Excel 模式和手动参数模式,Excel 路径存在时优先使用 Excel
|
||||
|
||||
### 物料上料/下料
|
||||
|
||||
#### `auto_feeding4to3`
|
||||
|
||||
自动化上料:从 4 号手套箱转运物料到 3 号手套箱
|
||||
|
||||
- **核心参数**: `xlsx_path`(Excel 物料模板路径)
|
||||
- **可选参数**: 同 `scheduler_start_and_auto_feeding` 的 WH4/WH3 点位参数
|
||||
- **返回**: 等待上料任务完成后返回结果
|
||||
|
||||
#### `auto_batch_outbound_from_xlsx`
|
||||
|
||||
自动化下料(从 Excel 读取下料信息)
|
||||
|
||||
- **核心参数**: `xlsx_path`(Excel 下料模板)
|
||||
- **Excel 列**: locationId, warehouseId, 数量, x, y, z
|
||||
|
||||
### 物料管理
|
||||
|
||||
#### `create_and_inbound_materials`
|
||||
|
||||
批量创建固体物料并入库
|
||||
|
||||
- **核心参数**: `material_names`(物料名称列表,默认 `["LiPF6", "LiDFOB", "DTD", "LiFSI", "LiPO2F2"]`)
|
||||
- **可选参数**: `type_id`(物料类型ID), `warehouse_name`(目标仓库,默认 "粉末加样头堆栈")
|
||||
- **流程**: 创建物料 → 批量入库 → 同步
|
||||
|
||||
#### `create_material`
|
||||
|
||||
创建单个物料并可选入库
|
||||
|
||||
- **核心参数**: `material_name`, `type_id`, `warehouse_name`
|
||||
- **可选参数**: `location_name_or_id`(库位编号如 "A01" 或 UUID)
|
||||
|
||||
#### `create_sample`
|
||||
|
||||
创建配液板物料(含子瓶)并入库
|
||||
|
||||
- **核心参数**: `name`, `board_type`(如 "5ml分液瓶板"), `bottle_type`(如 "5ml分液瓶"), `location_code`(如 "A01")
|
||||
- **可选参数**: `warehouse_name`(默认 "手动堆栈")
|
||||
- **备注**: 自动创建 2x4=8 个子瓶
|
||||
|
||||
#### `storage_inbound`
|
||||
|
||||
单个物料入库
|
||||
|
||||
- **核心参数**: `material_id`, `location_id`
|
||||
|
||||
#### `storage_batch_inbound`
|
||||
|
||||
批量物料入库
|
||||
|
||||
- **核心参数**: `items`(`[{materialId, locationId}, ...]`)
|
||||
|
||||
### 配液实验
|
||||
|
||||
#### `create_orders`
|
||||
|
||||
从 Excel 文件创建配液实验订单
|
||||
|
||||
- **核心参数**: `xlsx_path`(Excel 文件路径)
|
||||
- **Excel 列**: 配方ID, 创建日期, 配液瓶类型, 混匀时间(s), 扣电组装分液体积, 软包组装分液体积, 电导测试分液体积, 电导测试分液瓶数, 以及所有以 `(g)` 结尾的物料列
|
||||
- **流程**: 解析 Excel → 提交订单 → 等待全部完成 → 计算质量比 → 提取分液瓶板 → 创建资源树对象
|
||||
- **返回**: `{status, total_orders, bottle_count, reports, mass_ratios, vial_plates}`
|
||||
|
||||
#### `create_orders_formulation`
|
||||
|
||||
从配方列表创建配液实验订单(前端/API 输入版本)
|
||||
|
||||
- **核心参数**: `formulation`(配方数组)
|
||||
- **可选参数**: `batch_id`, `bottle_type`(默认 "配液小瓶"), `mix_time`(秒,列表), `coin_cell_volume`, `pouch_cell_volume`, `conductivity_volume`, `conductivity_bottle_count`
|
||||
- **formulation 格式**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"order_name": "配方A",
|
||||
"materials": [
|
||||
{"name": "LiPF6", "mass": 12.5},
|
||||
{"name": "EC", "mass": 50.0},
|
||||
{"name": "DMC", "mass": 37.5}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
- **返回**: 同 `create_orders`
|
||||
|
||||
### 物料转运
|
||||
|
||||
#### `transfer_3_to_2_to_1_auto`
|
||||
|
||||
**自动转运**:从 create_orders 结果中自动定位分液瓶板并转运到目标设备
|
||||
|
||||
- **核心参数**: `vial_plates`(分液瓶板列表,来自 create_orders 返回的 `vial_plates`)
|
||||
- **可选参数**: `target_device`(默认 "BatteryStation"), `target_location`(默认 "bottle_rack_6x2"), `mass_ratios`(配方信息)
|
||||
- **流程**: 遍历瓶板 → 解析 locationId → 调用 LIMS 转运 API → 更新资源树
|
||||
- **返回**: `{total, success, failed, results}`
|
||||
|
||||
#### `transfer_3_to_2_to_1`
|
||||
|
||||
3→2→1 物料转运(手动指定坐标)
|
||||
|
||||
- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z`
|
||||
|
||||
#### `transfer_3_to_2`
|
||||
|
||||
3→2 物料转运
|
||||
|
||||
- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z`
|
||||
|
||||
#### `transfer_1_to_2`
|
||||
|
||||
1→2 物料转运
|
||||
|
||||
- **核心参数**: 无
|
||||
|
||||
### 查询
|
||||
|
||||
#### `order_list_v2`
|
||||
|
||||
批量查询实验报告
|
||||
|
||||
- **可选参数**: `timeType`, `beginTime`, `endTime`, `status`(60=运行中, 80=完成, 90=失败), `filter`, `skipCount`, `pageCount`, `sorting`
|
||||
|
||||
---
|
||||
|
||||
## 扣电组装站 (`BatteryStation`)
|
||||
|
||||
源码:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||
|
||||
### 设备控制(组合操作)
|
||||
|
||||
#### `func_pack_device_init_auto_start_combined`
|
||||
|
||||
**组合操作**:设备初始化 → 物料搜寻确认 → 切换自动模式 → 启动
|
||||
|
||||
- **核心参数**: `material_search_enable`(是否启用物料搜寻,默认 `False`)
|
||||
- **前置检查**: REG_UNILAB_INTERACT=False, COIL_GB_L_IGNORE_CMD=False, 所有握手寄存器无残留
|
||||
- **流程**: 手动模式 → 初始化命令 → 监测物料搜寻弹窗并自动处理 → 自动模式 → 启动
|
||||
- **返回**: `True`/`False`
|
||||
- **备注**: 第一次运行必须调用此函数;后续批次调用 `func_sendbottle_allpack_multi`
|
||||
|
||||
### 批量组装
|
||||
|
||||
#### `func_sendbottle_allpack_multi`
|
||||
|
||||
**发送瓶数 + 批量组装**(适用于第二批次及后续批次)
|
||||
|
||||
- **核心参数**: `elec_num`(电解液瓶数), `elec_use_num`(每瓶组装电池数), `elec_vol`(电解液吸液量 μL,默认 50)
|
||||
- **可选参数**:
|
||||
- 双滴模式:`dual_drop_mode`(bool), `dual_drop_first_volume`(μL), `dual_drop_suction_timing`(bool), `dual_drop_start_timing`(bool)
|
||||
- 组装参数:`assembly_type`(7=不用铝箔垫/8=用), `assembly_pressure`(N,默认 4200)
|
||||
- 物料参数:`fujipian_panshu`, `fujipian_juzhendianwei`, `gemopanshu`, `gemo_juzhendianwei`, `qiangtou_juzhendianwei`
|
||||
- 开关:`lvbodian`(铝箔垫片), `battery_pressure_mode`(压力模式), `battery_clean_ignore`(忽略清洁)
|
||||
- 其他:`file_path`(CSV保存路径), `formulations`(配方信息,用于CSV追溯)
|
||||
- **流程**: 发送瓶数触发物料搬运 → 设置PLC参数 → 循环(等待PLC请求→下发参数→读取电池数据→写入CSV→更新资源树)→ 完成握手
|
||||
- **返回**: `{success, total_batteries, batteries, summary}`
|
||||
- **备注**: 设备已初始化后直接调用;`formulations` 来自 create_orders 的 `mass_ratios`
|
||||
|
||||
#### `func_allpack_cmd`
|
||||
|
||||
全套组装(基础版本,含断点续传)
|
||||
|
||||
- **核心参数**: `elec_num`, `elec_use_num`, `elec_vol`, `assembly_type`, `assembly_pressure`, `file_path`
|
||||
- **返回**: `{success, total_batteries, batteries, summary}`
|
||||
|
||||
#### `func_allpack_cmd_simp`
|
||||
|
||||
增强版组装(含双滴模式 + 负极片/隔膜/枪头参数)
|
||||
|
||||
- **核心参数**: 同 `func_sendbottle_allpack_multi`
|
||||
- **备注**: 被 `func_sendbottle_allpack_multi` 内部调用
|
||||
|
||||
### 设备控制(单步操作)
|
||||
|
||||
#### `func_pack_device_init`
|
||||
|
||||
设备初始化(手动模式 → 初始化 → 复位标志)
|
||||
|
||||
#### `func_pack_device_auto`
|
||||
|
||||
切换自动模式
|
||||
|
||||
#### `func_pack_device_start`
|
||||
|
||||
启动设备
|
||||
|
||||
#### `func_pack_device_stop`
|
||||
|
||||
设备停止
|
||||
|
||||
#### `func_pack_send_bottle_num`
|
||||
|
||||
发送电解液瓶数(触发物料搬运)
|
||||
|
||||
- **核心参数**: `bottle_num`(瓶数)
|
||||
|
||||
### PLC 参数设置
|
||||
|
||||
#### `qiming_coin_cell_code`
|
||||
|
||||
设置组装物料参数
|
||||
|
||||
- **核心参数**: `fujipian_panshu`(负极片盘数)
|
||||
- **可选参数**: `fujipian_juzhendianwei`, `gemopanshu`, `gemo_juzhendianwei`, `lvbodian`, `battery_pressure_mode`, `battery_pressure`, `battery_clean_ignore`
|
||||
|
||||
### 数据采集
|
||||
|
||||
#### `func_read_data_and_output`
|
||||
|
||||
持续数据采集并导出 CSV(后台循环运行)
|
||||
|
||||
- **核心参数**: `file_path`(CSV 保存目录)
|
||||
- **采集字段**: 开路电压, 极片质量, 组装时间, 压制力, 电解液加注量, 电池类型, 电解液二维码, 电池二维码
|
||||
|
||||
#### `func_stop_read_data`
|
||||
|
||||
停止 CSV 数据采集
|
||||
|
||||
### 设备状态属性(只读)
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `sys_status` | str | 设备状态(启动中/停止中/复位中/初始化中) |
|
||||
| `sys_mode` | str | 设备模式(手动/自动) |
|
||||
| `data_assembly_coin_cell_num` | int | 已完成电池数量 |
|
||||
| `data_assembly_time` | float | 单颗电池组装时间(秒) |
|
||||
| `data_open_circuit_voltage` | float | 开路电压(V) |
|
||||
| `data_pole_weight` | float | 正极片称重(g) |
|
||||
| `data_glove_box_pressure` | float | 手套箱压力(mbar) |
|
||||
| `data_glove_box_o2_content` | float | 手套箱氧含量(ppm) |
|
||||
| `data_glove_box_water_content` | float | 手套箱水含量(ppm) |
|
||||
| `data_coin_cell_code` | str | 电池二维码 |
|
||||
| `data_electrolyte_code` | str | 电解液二维码 |
|
||||
|
||||
---
|
||||
|
||||
## 配置参考
|
||||
|
||||
设备图文件 `yibin_electrolyte_config.json` 中的仓库映射(`warehouse_mapping`):
|
||||
|
||||
| 仓库名称 | 说明 | 典型操作 |
|
||||
|---------|------|---------|
|
||||
| 粉末加样头堆栈 | 20 个点位 (A01-T01) | `create_and_inbound_materials` 入库目标 |
|
||||
| 配液站内试剂仓库 | 9 个点位 (A01-C03) | 试剂存储 |
|
||||
| 自动堆栈-左 | 4 个点位 | 分液瓶板存放,`transfer_3_to_2_to_1_auto` 的源位置 |
|
||||
| 自动堆栈-右 | 4 个点位 | 分液瓶板存放 |
|
||||
| 手动传递窗左/右 | 各 15 个点位 | 人工上料/下料 |
|
||||
| 4号手套箱内部堆栈 | 12 个点位 | `auto_feeding4to3` 的源位置 |
|
||||
188
.cursorignore
Normal file
188
.cursorignore
Normal file
@@ -0,0 +1,188 @@
|
||||
# ============================================================
|
||||
# Uni-Lab-OS Cursor Ignore 配置,控制 Cursor AI 的文件索引范围
|
||||
# ============================================================
|
||||
|
||||
# ==================== 敏感配置文件 ====================
|
||||
# 本地配置(可能包含密钥)
|
||||
**/local_config.py
|
||||
test_config.py
|
||||
local_test*.py
|
||||
|
||||
# 环境变量和密钥
|
||||
.env
|
||||
.env.*
|
||||
**/.certs/
|
||||
*.pem
|
||||
*.key
|
||||
credentials.json
|
||||
secrets.yaml
|
||||
|
||||
# ==================== 二进制和 3D 模型文件 ====================
|
||||
# 3D 模型文件(无需索引)
|
||||
*.stl
|
||||
*.dae
|
||||
*.glb
|
||||
*.gltf
|
||||
*.obj
|
||||
*.fbx
|
||||
*.blend
|
||||
|
||||
# URDF/Xacro 机器人描述文件(大型XML)
|
||||
*.xacro
|
||||
|
||||
# 图片文件
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.webp
|
||||
*.ico
|
||||
*.svg
|
||||
*.bmp
|
||||
|
||||
# 压缩包
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.bz2
|
||||
*.rar
|
||||
*.7z
|
||||
|
||||
# ==================== Python 生成文件 ====================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.pyd
|
||||
*.egg
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# ==================== IDE 和编辑器 ====================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.#*
|
||||
|
||||
# ==================== 测试和覆盖率 ====================
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
# ==================== 虚拟环境 ====================
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# ==================== ROS 2 生成文件 ====================
|
||||
# ROS 构建目录
|
||||
build/
|
||||
install/
|
||||
log/
|
||||
logs/
|
||||
devel/
|
||||
|
||||
# ROS 消息生成
|
||||
msg_gen/
|
||||
srv_gen/
|
||||
msg/*Action.msg
|
||||
msg/*ActionFeedback.msg
|
||||
msg/*ActionGoal.msg
|
||||
msg/*ActionResult.msg
|
||||
msg/*Feedback.msg
|
||||
msg/*Goal.msg
|
||||
msg/*Result.msg
|
||||
msg/_*.py
|
||||
srv/_*.py
|
||||
build_isolated/
|
||||
devel_isolated/
|
||||
|
||||
# ROS 动态配置
|
||||
*.cfgc
|
||||
/cfg/cpp/
|
||||
/cfg/*.py
|
||||
|
||||
# ==================== 项目特定目录 ====================
|
||||
# 工作数据目录
|
||||
unilabos_data/
|
||||
|
||||
# 临时和输出目录
|
||||
temp/
|
||||
output/
|
||||
cursor_docs/
|
||||
configs/
|
||||
|
||||
# 文档构建
|
||||
docs/_build/
|
||||
/site
|
||||
|
||||
# ==================== 大型数据文件 ====================
|
||||
# 点云数据
|
||||
*.pcd
|
||||
|
||||
# GraphML 图形文件
|
||||
*.graphml
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
|
||||
# 数据库
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Jupyter 检查点
|
||||
.ipynb_checkpoints/
|
||||
|
||||
# ==================== 设备网格资源 ====================
|
||||
# 3D 网格文件目录(包含大量 STL/DAE 文件)
|
||||
unilabos/device_mesh/devices/**/*.stl
|
||||
unilabos/device_mesh/devices/**/*.dae
|
||||
unilabos/device_mesh/resources/**/*.stl
|
||||
unilabos/device_mesh/resources/**/*.glb
|
||||
unilabos/device_mesh/resources/**/*.xacro
|
||||
|
||||
# RViz 配置
|
||||
*.rviz
|
||||
|
||||
# ==================== 系统文件 ====================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# ==================== 锁文件 ====================
|
||||
poetry.lock
|
||||
Pipfile.lock
|
||||
pdm.lock
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# ==================== 类型检查缓存 ====================
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
.pytype/
|
||||
.pyre/
|
||||
pyrightconfig.json
|
||||
|
||||
# ==================== 其他 ====================
|
||||
# Catkin
|
||||
CATKIN_IGNORE
|
||||
|
||||
# Eclipse/Qt
|
||||
.project
|
||||
.cproject
|
||||
CMakeLists.txt.user
|
||||
*.user
|
||||
qtcreator-*
|
||||
11
.github/copilot-instructions.md
vendored
Normal file
11
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
## 设备接入
|
||||
|
||||
当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`。
|
||||
该指南包含完整的模板和已有设备接口参考。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 动作方法的参数名是接口契约,不可重命名
|
||||
- `status` 字符串必须与同类已有设备一致
|
||||
- `self.data` 必须在 `__init__` 中预填充所有属性字段
|
||||
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()`
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -251,7 +251,4 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
||||
*.bz2
|
||||
test_config.py
|
||||
|
||||
# Local config files with secrets
|
||||
yibin_coin_cell_only_config.json
|
||||
yibin_electrolyte_config.json
|
||||
yibin_electrolyte_only_config.json
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# CSV 导出功能变更概要
|
||||
|
||||
## 修改的文件
|
||||
|
||||
### 1. [bioyond_cell_workstation.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)
|
||||
|
||||
#### 新增导入
|
||||
- `import csv` 和 `import os`(L14-15)
|
||||
|
||||
#### 新增方法
|
||||
|
||||
| 方法 | 功能 |
|
||||
|------|------|
|
||||
| `_extract_prep_bottle_from_report` | 从 order_finish 报文提取**配液瓶**信息(每订单最多1个) |
|
||||
| `_extract_vial_bottles_from_report` | 从 order_finish 报文提取**分液瓶**信息(每订单可多个,返回数组) |
|
||||
| `_export_order_csv` | 汇总所有信息写入 CSV 文件 |
|
||||
|
||||
#### 配液瓶筛选逻辑 (`_extract_prep_bottle_from_report`)
|
||||
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
|
||||
- `locationId` 以 `3a19deae-2c7a-` 开头(手动传递窗)
|
||||
- LIMS API 二次确认:`typeName` 含"配液瓶(小)"或"配液瓶(大)"
|
||||
|
||||
#### 分液瓶筛选逻辑 (`_extract_vial_bottles_from_report`)
|
||||
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
|
||||
- `locationId` 以 `3a19debc-84b5-` 或 `3a19debe-5200` 开头(自动堆栈-左/右)
|
||||
- LIMS API 二次确认:`typeName` 为"5ml分液瓶"或"20ml分液瓶"
|
||||
- **返回数组**,支持 1×5ml + n×20ml 的组合
|
||||
|
||||
#### 修改的方法
|
||||
|
||||
| 方法 | 变更 |
|
||||
|------|------|
|
||||
| `_submit_and_wait_orders` | 新增配液瓶+分液瓶提取步骤,将 `prep_bottles` 和 `vial_bottles` 存入 `final_result` |
|
||||
| `create_orders` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
|
||||
| `create_orders_formulation` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
|
||||
|
||||
#### CSV 输出格式
|
||||
```
|
||||
orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间
|
||||
```
|
||||
- 单个分液瓶时直接写值;多个分液瓶时类型和二维码用 JSON 数组表示
|
||||
- CSV 编码使用 `utf-8-sig`(兼容 Excel 打开)
|
||||
- `csv_export_path` 默认为空字符串,不传则不导出(向后兼容)
|
||||
|
||||
---
|
||||
|
||||
### 2. [bioyond_cell.yaml](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/registry/devices/bioyond_cell.yaml)
|
||||
|
||||
为两个 action 注册了 `csv_export_path` 参数:
|
||||
|
||||
- `auto-create_orders`: `goal_default` + `schema.properties.goal.properties` 中添加 `csv_export_path`
|
||||
- `auto-create_orders_formulation`: 同上
|
||||
|
||||
---
|
||||
|
||||
### 3. [coin_cell_assembly.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py) 的 CSV 改动与全流程追溯
|
||||
|
||||
在 `bioyond_cell_workstation.py` 的 `_submit_and_wait_orders` 最后阶段,提取 `prep_bottles`(配液瓶)和 `vial_bottles`(分液瓶)的条码并随 `mass_ratios` 数组一起下发给各下游工站(例如扣电组装站),实现跨站的全流程配方追溯。
|
||||
|
||||
并在扣电站生成的 `date_xxx.csv` 中,**替换并新增**了以下列:
|
||||
- 移除了原有的 `formulation_order_code` 与合并的 `formulation_ratio` 列。
|
||||
- 新增 `orderName` 导出
|
||||
- 新增 `prep_bottle_barcode`(奔曜传递的配液瓶二维码)
|
||||
- 新增 `vial_bottle_barcodes`(奔曜传递的分液瓶二维码,多瓶时存 JSON 数组)
|
||||
- 新增 `target_mass_ratio` 理论目标质量比
|
||||
- 新增 `real_mass_ratio` 实际称量真实质量比
|
||||
|
||||
*注意:这与操作人员在手套箱内扫码传入扣电站的 `electrolyte_code` 是单独记录的,方便做数据核对。*
|
||||
|
||||
## 向后兼容性
|
||||
- `csv_export_path` 默认值为 `""`(空字符串),现有调用不受影响
|
||||
- 新增的 `prep_bottles` 和 `vial_bottles` 字段为 `final_result` 和 `mass_ratios` 内部的新增附属字段,不破坏现有数据结构。
|
||||
@@ -1,168 +0,0 @@
|
||||
# 变更说明 2026-03-24
|
||||
|
||||
## 问题背景
|
||||
|
||||
`BioyondElectrolyteDeck`(原 `BIOYOND_YB_Deck`)迁移后,前端物料未能正常上传/同步。
|
||||
|
||||
---
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. `unilabos/resources/bioyond/decks.py`
|
||||
|
||||
- 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑,与旧版 `BIOYOND_YB_Deck` 保持一致
|
||||
- 工厂函数 `bioyond_electrolyte_deck` 保留显式调用 `deck.setup()`,避免重复初始化
|
||||
|
||||
```python
|
||||
# 修复前(缺少 setup 参数,无法通过 setup=True 触发初始化)
|
||||
def __init__(self, name, size_x, size_y, size_z, category):
|
||||
super().__init__(...)
|
||||
|
||||
# 修复后
|
||||
def __init__(self, name, size_x, size_y, size_z, category, setup: bool = False):
|
||||
super().__init__(...)
|
||||
if setup:
|
||||
self.setup()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `unilabos/resources/graphio.py`
|
||||
|
||||
- 修复 `resource_bioyond_to_plr` 中两处 `bottle.tracker.liquids` 直接赋值导致的崩溃
|
||||
- `ResourceHolder`(如枪头盒的 TipSpot 槽位)没有 `tracker` 属性,直接访问会抛出 `AttributeError`,阻断整个 Bioyond 同步流程
|
||||
|
||||
```python
|
||||
# 修复前
|
||||
bottle.tracker.liquids = [...]
|
||||
|
||||
# 修复后
|
||||
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
|
||||
bottle.tracker.liquids = [...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. `unilabos/app/main.py`
|
||||
|
||||
- 保留 `file_path is not None` 条件不变(已还原),并补充注释说明原因
|
||||
- 该逻辑只在**本地文件模式**下有意义:本地 graph 文件只含设备结构,远端有已保存物料,merge 才能将两者合并
|
||||
- 远端模式(`file_path=None`)下,`resource_tree_set` 和 `request_startup_json` 来自同一份数据,merge 为空操作,条件是否加 `file_path is not None` 对结果没有影响
|
||||
|
||||
---
|
||||
|
||||
### 4. `unilabos/devices/workstation/bioyond_studio/station.py` ⭐ 核心修复
|
||||
|
||||
- 当 deck 通过反序列化创建时,不会自动调用 `setup()`,导致 `deck.children` 为空,`warehouses` 始终是 `{}`
|
||||
- 增加兜底逻辑:仓库扫描后仍为空,则主动调用 `deck.setup()` 初始化仓库
|
||||
- 这是导致所有物料放置失败(`warehouse '...' 在deck中不存在。可用warehouses: []`)的根本原因
|
||||
|
||||
```python
|
||||
# 新增兜底
|
||||
if not self.deck.warehouses and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
|
||||
self.deck.setup()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 补充修复 2026-03-25:依华扣电组装工站子物料未上传
|
||||
|
||||
### 问题
|
||||
|
||||
`CoinCellAssemblyWorkstation.post_init` 直接上传空 deck,未调用 `deck.setup()`,导致:
|
||||
- 前端子物料(成品弹夹、料盘、瓶架等)不显示
|
||||
- 运行时 `self.deck.get_resource("成品弹夹")` 抛出 `ResourceNotFoundError`
|
||||
|
||||
### 修复文件
|
||||
|
||||
**`unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`**
|
||||
- `YihuaCoinCellDeck.__init__` 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑
|
||||
|
||||
**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`**
|
||||
- `post_init` 中增加与 Bioyond 工站相同的兜底逻辑:deck 无子节点时调用 `deck.setup()` 初始化
|
||||
|
||||
```python
|
||||
# post_init 中新增
|
||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
|
||||
self.deck.setup()
|
||||
```
|
||||
|
||||
### 联动 Bug:`MaterialPlate.create_with_holes` 构造顺序错误
|
||||
|
||||
**现象**:`deck.setup()` 被调用后,启动时抛出:
|
||||
```
|
||||
设备后初始化失败: Must specify either `ordered_items` or `ordering`.
|
||||
```
|
||||
|
||||
**根因**:`create_with_holes` 原来的逻辑是先构造空的 `MaterialPlate` 实例,再 assign 洞位:
|
||||
```python
|
||||
# 旧(错误):cls(...) 时 ordered_items=None → ItemizedResource.__init__ 立即报错
|
||||
plate = cls(name=name, ...) # ← 这里就崩了
|
||||
holes = create_ordered_items_2d(...) # ← 根本没走到这里
|
||||
for hole_name, hole in holes.items():
|
||||
plate.assign_child_resource(...)
|
||||
```
|
||||
pylabrobot 的 `ItemizedResource.__init__` 强制要求 `ordered_items` 和 `ordering` 必须有一个不为 `None`,空构造直接失败。
|
||||
|
||||
**修复**:先建洞位,再作为 `ordered_items` 传给构造函数:
|
||||
```python
|
||||
# 新(正确):先建洞位,再一次性传入构造函数
|
||||
holes = create_ordered_items_2d(klass=MaterialHole, num_items_x=4, ...)
|
||||
return cls(name=name, ..., ordered_items=holes)
|
||||
```
|
||||
|
||||
> 此 bug 此前未被触发,是因为 `deck.setup()` 从未被调用到——正是上面 `post_init` 兜底修复引出的联动问题。
|
||||
|
||||
---
|
||||
|
||||
## 补充修复 2026-03-25:3→2→1 转运资源同步失败
|
||||
|
||||
### 问题
|
||||
|
||||
配液工站(Bioyond)完成分液后,调用 `transfer_3_to_2_to_1_auto` 将分液瓶板转运到扣电工站(BatteryStation)。物理 LIMS 转运成功,但数字孪生资源树同步始终失败:
|
||||
```
|
||||
[资源同步] ❌ 失败: 目标设备 'BatteryStation' 中未找到资源 'bottle_rack_6x2'
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`_get_resource_from_device` 方法负责跨设备查找资源对象,有两个问题:
|
||||
|
||||
1. **原始路径完全失效**:尝试 `from unilabos.app.ros2_app import get_device_plr_resource_by_name`,但该模块不存在,`ImportError` 被 `except Exception: pass` 静默吞掉
|
||||
2. **降级路径搜错地方**:遍历 `self._plr_resources`(Bioyond 自己的资源),不可能找到 BatteryStation 的 `bottle_rack_6x2`
|
||||
|
||||
### 修复文件
|
||||
|
||||
**`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`**
|
||||
|
||||
改用全局设备注册表 `registered_devices` 跨设备访问目标 deck:
|
||||
|
||||
```python
|
||||
# 修复前(失效)
|
||||
from unilabos.app.ros2_app import get_device_plr_resource_by_name # 模块不存在
|
||||
return get_device_plr_resource_by_name(device_id, resource_name)
|
||||
|
||||
# 修复后
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
device_info = registered_devices.get(device_id)
|
||||
if device_info is not None:
|
||||
driver = device_info.get("driver_instance") # TypedDict 是 dict,必须用 .get()
|
||||
if driver is not None:
|
||||
deck = getattr(driver, "deck", None)
|
||||
if deck is not None:
|
||||
res = deck.get_resource(resource_name)
|
||||
```
|
||||
|
||||
关键细节:`DeviceInfoType` 是 `TypedDict`(即普通 `dict`),必须用 `device_info.get("driver_instance")` 而非 `getattr(device_info, "driver_instance", None)`——后者对字典永远返回 `None`。
|
||||
|
||||
---
|
||||
|
||||
## 根本原因分析
|
||||
|
||||
旧版以**本地文件模式**启动(有 `graph` 文件),deck 在启动前已通过 `merge_remote_resources` 获得仓库子节点,反序列化时能正确恢复 warehouses。
|
||||
|
||||
新版以**远端模式**启动(`file_path=None`),deck 反序列化时没有仓库子节点,`station.py` 扫描为空,所有物料的 warehouse 匹配失败,Bioyond 同步的 16 个资源全部无法放置到对应仓库位,前端不显示。
|
||||
1100
docs/ai_guides/add_device.md
Normal file
1100
docs/ai_guides/add_device.md
Normal file
File diff suppressed because it is too large
Load Diff
344
docs/ai_guides/agent_prompt_template.md
Normal file
344
docs/ai_guides/agent_prompt_template.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Uni-Lab-OS 设备接入 Agent — 提示词模板
|
||||
|
||||
> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。
|
||||
> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。
|
||||
|
||||
---
|
||||
|
||||
## 系统提示词模板
|
||||
|
||||
以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。
|
||||
|
||||
---
|
||||
|
||||
### 开始复制 ↓
|
||||
|
||||
```
|
||||
你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。
|
||||
|
||||
你能做的事:
|
||||
- 根据用户描述,生成完整的设备驱动代码(Python)、注册表(YAML)和实验图文件(JSON)
|
||||
- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式
|
||||
- 诊断已有驱动代码的接口对齐问题
|
||||
|
||||
你不能做的事:
|
||||
- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取)
|
||||
- 替代真实硬件联调测试
|
||||
|
||||
## 知识来源
|
||||
|
||||
{{KNOWLEDGE_LOADING}}
|
||||
|
||||
## 工作流程
|
||||
|
||||
当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。
|
||||
|
||||
### 阶段 1:设备画像(交互)
|
||||
|
||||
向用户收集以下三个信息,可以一次性提问:
|
||||
|
||||
1. **设备类别** — 属于以下哪一种?
|
||||
- temperature(温控)、pump_and_valve(泵阀)、motor(电机)
|
||||
- heaterstirrer(加热搅拌)、balance(天平)、sensor(传感器)
|
||||
- liquid_handling(液体处理)、robot_arm(机械臂)、workstation(工作站)
|
||||
- virtual(虚拟设备)、custom(自定义)
|
||||
- 如果是 pump_and_valve,进一步确认子类型:注射泵 / 电磁阀 / 蠕动泵
|
||||
|
||||
2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b)
|
||||
|
||||
3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟)
|
||||
|
||||
⏸️ **暂停:等待用户回答后继续**
|
||||
|
||||
### 阶段 2:指令协议收集(交互)
|
||||
|
||||
根据上一步确定的通信协议,引导用户提供指令信息:
|
||||
|
||||
- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑
|
||||
- 如果用户有 **协议文档**:请用户提供文档(PDF/图片/文本),你从中解析指令格式
|
||||
- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令
|
||||
- 如果是 **标准协议**(Modbus 寄存器表、SCPI):请用户提供寄存器/指令映射
|
||||
- 如果是 **虚拟设备**:跳过此阶段
|
||||
|
||||
⏸️ **暂停:确认已获取足够的指令协议信息**
|
||||
|
||||
### 阶段 3:确认摘要
|
||||
|
||||
在开始生成代码前,向用户展示你的理解摘要:
|
||||
|
||||
```
|
||||
设备接入摘要:
|
||||
- 设备名称:<name>
|
||||
- 设备类别:<category>(<subtype>)
|
||||
- 通信协议:<protocol>
|
||||
- 指令来源:<source>
|
||||
- 将要实现的属性:<list>
|
||||
- 将要实现的动作:<list>
|
||||
- 同类已有设备:<existing>(将对齐其接口)
|
||||
```
|
||||
|
||||
⏸️ **暂停:用户确认"没问题"后再生成代码**
|
||||
|
||||
### 阶段 4:自动生成(无需暂停)
|
||||
|
||||
按以下顺序自动执行:
|
||||
|
||||
1. **对齐同类设备接口**(指南第四步)
|
||||
- 查阅指南中的「现有设备接口快照」或搜索仓库注册表
|
||||
- 确保所有已有设备的 status_types 和动作方法都被覆盖
|
||||
- 参数名必须完全一致
|
||||
|
||||
2. **生成驱动代码** — `unilabos/devices/<category>/<name>.py`
|
||||
|
||||
3. **生成注册表** — `unilabos/registry/devices/<name>.yaml`(最小配置)
|
||||
|
||||
4. **生成图文件** — `unilabos/test/experiments/graph_example_<name>.json`
|
||||
|
||||
### 阶段 5:验证输出
|
||||
|
||||
生成完成后,逐项检查对齐验证清单并展示结果:
|
||||
|
||||
```
|
||||
对齐验证清单:
|
||||
- [x] 所有动作方法的参数名与已有设备完全一致
|
||||
- [x] status 属性返回的字符串值与已有设备一致
|
||||
- [x] 已有设备的所有 status_types 字段都有对应 @property
|
||||
- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法
|
||||
- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值
|
||||
- [x] 串口/二进制协议的响应解析先定位帧起始标记
|
||||
```
|
||||
|
||||
如果有未通过的项,主动修复后再展示。
|
||||
|
||||
## 硬约束(违反任何一条都会导致设备接入失败)
|
||||
|
||||
1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity)是接口契约,框架通过参数名分派调用。绝不能加后缀(如 volume_ml)、改名(如 speed_ml_s)。单位写在 docstring 中。
|
||||
|
||||
2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。
|
||||
|
||||
3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。
|
||||
|
||||
4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。
|
||||
|
||||
5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE),禁止用硬编码索引直接解析。
|
||||
|
||||
6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。
|
||||
|
||||
7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位(mL、°C、RPM),驱动内部负责转换到硬件原始值(步数、Hz、寄存器值)。
|
||||
|
||||
## 代码骨架参考
|
||||
|
||||
所有设备驱动遵循以下结构:
|
||||
|
||||
```python
|
||||
import logging
|
||||
import time as time_module
|
||||
from typing import Dict, Any
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
except ImportError:
|
||||
BaseROS2DeviceNode = None
|
||||
|
||||
class MyDevice:
|
||||
_ros_node: "BaseROS2DeviceNode"
|
||||
|
||||
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
|
||||
if device_id is None and 'id' in kwargs:
|
||||
device_id = kwargs.pop('id')
|
||||
if config is None and 'config' in kwargs:
|
||||
config = kwargs.pop('config')
|
||||
self.device_id = device_id or "unknown_device"
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
self.data = {
|
||||
"status": "Idle",
|
||||
# 所有 @property 的键都必须在此预填充
|
||||
}
|
||||
|
||||
def post_init(self, ros_node: "BaseROS2DeviceNode"):
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
self.data["status"] = "Idle"
|
||||
return True
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
self.data["status"] = "Offline"
|
||||
return True
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Idle")
|
||||
```
|
||||
|
||||
## 注册表最小配置
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
class:
|
||||
module: unilabos.devices.<category>.<file>:MyDevice
|
||||
type: python
|
||||
```
|
||||
|
||||
启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。
|
||||
|
||||
## 图文件模板
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_device_1",
|
||||
"name": "设备名称",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "my_device",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 现有设备接口快照(对齐用)
|
||||
|
||||
对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本:
|
||||
https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/
|
||||
|
||||
### pump_and_valve — 注射泵
|
||||
|
||||
已有设备:syringe_pump_with_valve.runze.SY03B-T06
|
||||
|
||||
属性:status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String)
|
||||
|
||||
方法签名(参数名不可改):
|
||||
- initialize()
|
||||
- set_valve_position(position)
|
||||
- set_position(position: float, max_velocity: float = None)
|
||||
- pull_plunger(volume: float)
|
||||
- push_plunger(volume: float)
|
||||
- set_max_velocity(velocity: float)
|
||||
- set_velocity_grade(velocity)
|
||||
- stop_operation()
|
||||
|
||||
### pump_and_valve — 电磁阀
|
||||
|
||||
属性:status(str), valve_position(str)
|
||||
方法:open(), close(), set_valve_position(position), is_open(), is_closed()
|
||||
|
||||
### temperature
|
||||
|
||||
属性:status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C)
|
||||
|
||||
### motor
|
||||
|
||||
属性:status(str), position(int)
|
||||
|
||||
### sensor
|
||||
|
||||
属性:level(bool), rssi(int)
|
||||
```
|
||||
|
||||
### 结束复制 ↑
|
||||
|
||||
---
|
||||
|
||||
## `{{KNOWLEDGE_LOADING}}` 变量替换
|
||||
|
||||
根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容:
|
||||
|
||||
### 方案 A:有知识库(Custom GPT / Claude Project)
|
||||
|
||||
```
|
||||
你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。
|
||||
执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
|
||||
本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。
|
||||
```
|
||||
|
||||
### 方案 B:有联网能力
|
||||
|
||||
```
|
||||
执行工作流前,从以下 URL 获取完整的设备接入指南:
|
||||
https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md
|
||||
|
||||
该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
|
||||
如果无法访问 URL,使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。
|
||||
```
|
||||
|
||||
### 方案 C:无知识库、无联网
|
||||
|
||||
```
|
||||
完整的设备接入指南需要用户在对话中提供。
|
||||
如果用户未主动提供,请在阶段 1 开始前询问:
|
||||
"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。"
|
||||
|
||||
本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。
|
||||
但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 各平台配置指南
|
||||
|
||||
### OpenAI Custom GPT
|
||||
|
||||
1. 进入 https://chat.openai.com/gpts/editor
|
||||
2. **Name**:Uni-Lab-OS 设备接入助手
|
||||
3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。
|
||||
4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
|
||||
5. **Knowledge**:上传 `docs/ai_guides/add_device.md`
|
||||
6. **Capabilities**:开启 Code Interpreter(用于代码验证)
|
||||
7. **Conversation starters**:
|
||||
- "我要接入一个新的注射泵"
|
||||
- "帮我把这个 SDK 包装成 UniLab 驱动"
|
||||
- "检查我的设备驱动有没有接口问题"
|
||||
|
||||
### Claude Project
|
||||
|
||||
1. 创建新 Project
|
||||
2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
|
||||
3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md`
|
||||
|
||||
### API Agent(LangChain / AutoGen / 自建框架)
|
||||
|
||||
```python
|
||||
system_prompt = """
|
||||
<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B>
|
||||
"""
|
||||
|
||||
# 如果框架支持工具调用,可注册以下工具:
|
||||
tools = [
|
||||
{
|
||||
"name": "fetch_device_guide",
|
||||
"description": "获取最新的 Uni-Lab-OS 设备接入指南",
|
||||
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md"
|
||||
},
|
||||
{
|
||||
"name": "fetch_registry",
|
||||
"description": "获取最新的设备注册表",
|
||||
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml"
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Cursor Agent Mode
|
||||
|
||||
无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力(Grep 搜索注册表、AskQuestion 收集信息等)。
|
||||
|
||||
### 纯网页对话(ChatGPT / Claude 无 Project)
|
||||
|
||||
1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C)
|
||||
2. 第二条消息上传或粘贴 `add_device.md`
|
||||
3. 第三条消息开始描述设备
|
||||
|
||||
---
|
||||
|
||||
## 维护说明
|
||||
|
||||
- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分
|
||||
- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分
|
||||
- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分
|
||||
- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护
|
||||
@@ -18,13 +18,15 @@ Uni-Lab 开发团队在仓库中提供了 3 个样例:
|
||||
|
||||
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||
- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
||||
- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`。详细框架说明请参考 {doc}`plc_framework`
|
||||
|
||||
---
|
||||
|
||||
## 其他工业通信协议:CANopen, Ethernet, OPCUA...
|
||||
|
||||
【敬请期待】
|
||||
Uni-Lab 已实现基于 OPC UA 协议的 PLC 接管框架,用于后处理工站等项目。与 Modbus 框架相比,OPC UA 框架额外提供了自动节点发现、订阅推送、断线重连等特性。详细说明请参考 {doc}`plc_framework`。
|
||||
|
||||
其他协议(CANopen、EtherCAT 等)【敬请期待】
|
||||
|
||||
## 没有接口的老设备老软件:使用 PyWinAuto
|
||||
|
||||
|
||||
281
docs/developer_guide/plc_framework.md
Normal file
281
docs/developer_guide/plc_framework.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# PLC 设备接管框架
|
||||
|
||||
> 本文档面向初次接触 UniLab-OS 的开发者,介绍系统如何通过工业协议"接管"(连接并控制)PLC 设备。
|
||||
|
||||
## 什么是"PLC 接管"?
|
||||
|
||||
**PLC**(可编程逻辑控制器)是工厂设备的控制核心,驱动机械臂、泵、阀门等硬件。UniLab-OS 通过网络协议直接读写 PLC 内部变量,从而控制设备运行:
|
||||
|
||||
```
|
||||
UniLab-OS(Python) ←通信协议→ PLC ←电信号→ 电机/气缸/传感器
|
||||
```
|
||||
|
||||
UniLab-OS 提供两套接管框架,对应两种工业协议:
|
||||
|
||||
| 框架 | 协议 | 应用项目 | 核心文件 |
|
||||
| --------------- | ---------------- | ------------------ | ----------------------------------------------------------- |
|
||||
| **Modbus 框架** | Modbus TCP / RTU | 扣式电池装配工站 | `unilabos/device_comms/modbus_plc/client.py` |
|
||||
| **OPC UA 框架** | OPC UA | 后处理工站(怀柔) | `unilabos/devices/workstation/post_process/post_process.py` |
|
||||
|
||||
两套框架**设计思想完全一致**,底层通信协议不同。理解一个,另一个基本触类旁通。
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 节点(Node)
|
||||
|
||||
节点是 PLC 内部一个具体的变量地址,可以理解为 PLC 的一个输入/输出端口。
|
||||
|
||||
| 属性 | 说明 | 示例 |
|
||||
| ---- | -------------------------------------- | -------------------- |
|
||||
| 名称 | 人类可读标识 | `COIL_SYS_START_CMD` |
|
||||
| 地址 | PLC 内存地址 | `0x0064` |
|
||||
| 类型 | Coil / HoldRegister / InputRegister 等 | `coil` |
|
||||
|
||||
```
|
||||
PLC 内存空间
|
||||
├── Coil 区: True / False ← 控制开关量(启动/停止/复位)
|
||||
├── Hold Reg: 120, 35.5 … ← 存参数值(速度、位置)
|
||||
└── Input Reg: 99.8, 42 … ← 只读传感器数据
|
||||
```
|
||||
|
||||
### 动作生命周期(Action Lifecycle)
|
||||
|
||||
每个设备动作被拆分为 4 个阶段,用 `try/finally` 保证安全性:
|
||||
|
||||
```python
|
||||
try:
|
||||
init(...) # 写入参数(速度、位置等)— 可选
|
||||
start(...) # 发触发信号 + 轮询等待完成
|
||||
stop(...) # 复位触发信号(成功时执行)
|
||||
except:
|
||||
is_err = True
|
||||
finally:
|
||||
cleanup(...) # 无论成败都执行,作为安全兜底
|
||||
```
|
||||
|
||||
| 阶段 | 何时执行 | 典型内容 |
|
||||
| --------- | ----------------------- | ------------------------------------ |
|
||||
| `init` | 成功路径(可选) | 写运动速度 = 20.0 |
|
||||
| `start` | 成功路径 | 写启动位 = True,等待完成位 = True |
|
||||
| `stop` | 成功路径 | 写启动位 = False(正常复位) |
|
||||
| `cleanup` | **无论成败**(finally) | 安全兜底复位,防止异常时设备持续运动 |
|
||||
|
||||
> **为什么 `cleanup` 无论成败都执行?**
|
||||
> 若 `start` 阶段因传感器故障抛出异常,`stop` 会被跳过,PLC 触发位仍为 `True`——设备可能持续运动。`cleanup` 放在 `finally` 块中,作为最后的安全保障,确保 PLC 一定被复位到安全状态。实际上大多数动作将 `cleanup` 设为 `null`,由 `stop` 负责正常复位即可。
|
||||
|
||||
---
|
||||
|
||||
## Modbus 框架
|
||||
|
||||
**核心文件**:`unilabos/device_comms/modbus_plc/client.py`
|
||||
**参考实现**:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||
|
||||
### 连接与节点注册
|
||||
|
||||
```python
|
||||
from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient
|
||||
|
||||
# 1. 建立 TCP 连接
|
||||
client = TCPClient(addr="172.16.28.102", port=502)
|
||||
client.client.connect()
|
||||
|
||||
# 2. 从 CSV 加载节点定义
|
||||
nodes = BaseClient.load_csv("coin_cell_assembly_b.csv")
|
||||
|
||||
# 3. 注册节点,之后可按名称访问
|
||||
client.register_node_list(nodes)
|
||||
|
||||
# 访问节点
|
||||
client.use_node('COIL_SYS_START_CMD').write(True)
|
||||
value, err = client.use_node('COIL_SYS_START_STATUS').read(1)
|
||||
```
|
||||
|
||||
**CSV 格式**(`Name` / `DeviceType` / `Address` / `DataType`):
|
||||
|
||||
| Name | DeviceType | Address | DataType |
|
||||
| ------------------ | ------------- | ------- | -------- |
|
||||
| COIL_SYS_START_CMD | coil | 100 | INT16 |
|
||||
| REG_SPEED | hold_register | 200 | FLOAT32 |
|
||||
|
||||
### 三段式接管流程(扣式电池工站)
|
||||
|
||||
PLC 设备通常需要按固定顺序切换模式,以扣式电池工站为例:
|
||||
|
||||
```
|
||||
Python PLC
|
||||
│── 写 HAND_CMD = True ─────────>│ 切换到手动模式
|
||||
│<─ 读 HAND_STATUS = True ────────│ 确认进入手动
|
||||
│── 写 INIT_CMD = True ──────────>│ 执行初始化
|
||||
│<─ 读 INIT_STATUS = True ─────────│ 初始化完成
|
||||
│── 写 HAND_CMD = False ──────────>│ 复位(脉冲信号)
|
||||
│── 写 INIT_CMD = False ──────────>│ 复位
|
||||
│── 写 AUTO_CMD = True ───────────>│ 切换自动模式
|
||||
│<─ 读 AUTO_STATUS = True ─────────│ 自动模式就绪
|
||||
│── 写 AUTO_CMD = False ──────────>│ 复位
|
||||
│── 写 START_CMD = True ──────────>│ 开始运行
|
||||
│<─ 读 START_STATUS = True ────────│ 运行确认
|
||||
│── 写 START_CMD = False ──────────>│ 复位
|
||||
```
|
||||
|
||||
> **脉冲信号模式**:命令写 `True` → 等待 PLC 状态位确认 → 命令写回 `False`,这是大多数 PLC 的标准触发方式,而不是保持高电平。
|
||||
|
||||
### JSON 配置方式
|
||||
|
||||
Modbus 框架支持纯 JSON 配置,无需手写 Python 流程:
|
||||
|
||||
```json
|
||||
{
|
||||
"register_node_list_from_csv_path": {"path": "M01.csv"},
|
||||
"create_flow": [
|
||||
{
|
||||
"name": "归位",
|
||||
"action": [{
|
||||
"address_function_to_create": [
|
||||
{"func_name": "pos_tip", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": true},
|
||||
{"func_name": "pos_tip_read", "node_name": "M01_idlepos_coil_r", "mode": "read", "value": 1},
|
||||
{"func_name": "manual_stop", "node_name": "M01_manual_stop_coil_r","mode": "read", "value": 1}
|
||||
],
|
||||
"create_init_function": {"func_name": "idel_init", "node_name": "M01_idlepos_velocity_rw", "mode": "write", "value": 20.0},
|
||||
"create_start_function": {
|
||||
"func_name": "idel_position",
|
||||
"write_functions": ["pos_tip"],
|
||||
"condition_functions": ["pos_tip_read", "manual_stop"],
|
||||
"stop_condition_expression": "pos_tip_read[0] and manual_stop[0]"
|
||||
},
|
||||
"create_stop_function": {"func_name": "idel_stop", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": false},
|
||||
"create_cleanup_function": null
|
||||
}]
|
||||
}
|
||||
],
|
||||
"execute_flow": ["归位"]
|
||||
}
|
||||
```
|
||||
|
||||
执行:
|
||||
|
||||
```python
|
||||
client.execute_procedure_from_json(json_data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OPC UA 框架
|
||||
|
||||
**核心文件**:`unilabos/devices/workstation/post_process/post_process.py`
|
||||
**参考实现**:`unilabos/devices/workstation/post_process/opcua_huairou.json`
|
||||
|
||||
### 与 Modbus 的主要区别
|
||||
|
||||
| 特性 | Modbus | OPC UA |
|
||||
| ---------- | -------------------- | --------------------------------- |
|
||||
| 节点发现 | 手动填写 CSV 地址 | **自动遍历**服务器节点树 |
|
||||
| 数据获取 | 轮询(主动问) | **订阅推送**(有变化时通知) |
|
||||
| 节点标识 | 数字地址(如 `100`) | 字符串 NodeId(如 `ns=2;s=速度`) |
|
||||
| 断线处理 | 无 | **后台监控线程**自动重连 |
|
||||
| 认证安全 | 无 | 支持用户名/密码 |
|
||||
| 工作流调用 | 手动调用 | **自动注册为实例方法** |
|
||||
|
||||
### 连接与节点发现
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.post_process.post_process import OpcUaClient
|
||||
|
||||
client = OpcUaClient(
|
||||
url="opc.tcp://192.168.1.100:4840",
|
||||
username="admin", # 可选
|
||||
password="123456", # 可选
|
||||
config_path="opcua_huairou.json", # 自动加载工作流配置
|
||||
cache_timeout=5.0, # 节点值缓存 5 秒
|
||||
subscription_interval=500, # 每 500ms 接收推送
|
||||
)
|
||||
|
||||
# 节点自动通过订阅保持最新值,读取直接查本地缓存
|
||||
value = client.get_node_value("grab_complete")
|
||||
```
|
||||
|
||||
### JSON 配置方式
|
||||
|
||||
```json
|
||||
{
|
||||
"register_node_list_from_csv_path": {"path": "opcua_nodes_huairou.csv"},
|
||||
"create_flow": [
|
||||
{
|
||||
"name": "trigger_grab_action",
|
||||
"description": "触发反应罐及原料罐抓取动作",
|
||||
"parameters": ["reaction_tank_number", "raw_tank_number"],
|
||||
"action": [{
|
||||
"init_function": {
|
||||
"func_name": "init_grab_params",
|
||||
"write_nodes": ["reaction_tank_number", "raw_tank_number"]
|
||||
},
|
||||
"start_function": {
|
||||
"func_name": "start_grab",
|
||||
"write_nodes": {"grab_trigger": true},
|
||||
"condition_nodes": ["grab_complete"],
|
||||
"stop_condition_expression": "grab_complete == True",
|
||||
"timeout_seconds": 999999.0
|
||||
},
|
||||
"stop_function": {
|
||||
"func_name": "stop_grab",
|
||||
"write_nodes": {"grab_trigger": false}
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
配置加载后,工作流自动注册为实例方法:
|
||||
|
||||
```python
|
||||
# 直接调用,传入参数,框架自动写入对应节点
|
||||
client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 新增设备快速上手
|
||||
|
||||
### 使用 Modbus 框架
|
||||
|
||||
```
|
||||
1. 从 PLC 工程师处拿到地址表,按格式填写 CSV(Name/DeviceType/Address/DataType)
|
||||
2. 继承 BaseClient,在 __init__ 中连接并注册节点
|
||||
3. 参考 coin_cell_assembly.py 编写三段式接管函数(手动→初始化→自动→启动)
|
||||
4. 或直接编写 JSON 配置,调用 execute_procedure_from_json()
|
||||
```
|
||||
|
||||
### 使用 OPC UA 框架
|
||||
|
||||
```
|
||||
1. 确认设备支持 OPC UA,拿到服务器 URL(格式:opc.tcp://IP:PORT)
|
||||
2. 准备 CSV 节点定义文件(可选,也可让框架自动发现)
|
||||
3. 编写 JSON 配置:定义 parameters、init/start/stop 函数
|
||||
4. 实例化 OpcUaClient,传入 config_path,直接调用自动注册的工作流方法
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: `node {name} is not registered` 报错?**
|
||||
A: 节点名不在 CSV 或未调用 `register_node_list_from_csv_path()`。
|
||||
|
||||
**Q: 程序卡死在 `while not status(): sleep(1)`?**
|
||||
A: PLC 未返回预期完成信号。检查:PLC 是否在正确运行模式、状态位地址是否正确、PLC 有无报警。
|
||||
|
||||
**Q: OPC UA 连接成功但读不到节点?**
|
||||
A: 检查节点名称是否与服务器显示名一致(区分中英文)。可调用 `_find_nodes()` 打印服务器全部节点。
|
||||
|
||||
**Q: 应该选 Modbus 还是 OPC UA?**
|
||||
A: 取决于设备支持的协议,由 PLC 工程师决定。OPC UA 功能更完整,条件允许优先选择。
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
- {doc}`add_device` - 将驱动集成进 UniLab-OS 设备节点
|
||||
- {doc}`add_action` - 为设备添加可调度的动作指令
|
||||
- {doc}`add_yaml` - 编写设备注册表 YAML 文件
|
||||
@@ -17,6 +17,9 @@ developer_guide/http_api.md
|
||||
developer_guide/networking_overview.md
|
||||
developer_guide/add_device.md
|
||||
developer_guide/add_action.md
|
||||
developer_guide/add_old_device.md
|
||||
developer_guide/plc_framework.md
|
||||
developer_guide/add_protocol.md
|
||||
developer_guide/add_registry.md
|
||||
developer_guide/add_yaml.md
|
||||
developer_guide/action_includes.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.19
|
||||
version: 0.11.1
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.19"
|
||||
version: "0.11.1"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.19',
|
||||
version='0.11.1',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.19"
|
||||
__version__ = "0.11.1"
|
||||
|
||||
@@ -12,6 +12,15 @@ from typing import Dict, Any, List
|
||||
import networkx as nx
|
||||
import yaml
|
||||
|
||||
# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符
|
||||
# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃
|
||||
if sys.platform == "win32":
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
# 首先添加项目根目录到路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
@@ -621,8 +630,6 @@ def main():
|
||||
continue
|
||||
|
||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||
# 仅在本地文件模式下有意义:本地文件只含设备结构,远端有已保存的物料,需要 merge
|
||||
# 远端模式下 resource_tree_set 与 request_startup_json 来自同一份数据,merge 为空操作
|
||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||
print_status("开始同步远端物料到本地...", "info")
|
||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端模块
|
||||
|
||||
提供设备后端接口和实现
|
||||
"""
|
||||
|
||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||
from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend
|
||||
|
||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||
__all__ = ['UniLiquidHandlerLaiyuBackend']
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端实现
|
||||
|
||||
提供设备的后端接口和控制逻辑
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# 尝试导入PyLabRobot后端
|
||||
try:
|
||||
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
# 创建模拟后端基类
|
||||
class LiquidHandlerBackend:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.is_connected = False
|
||||
|
||||
def connect(self):
|
||||
"""连接设备"""
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
pass
|
||||
|
||||
|
||||
class LaiYuLiquidBackend(LiquidHandlerBackend):
|
||||
"""LaiYu液体处理设备后端"""
|
||||
|
||||
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
|
||||
"""
|
||||
初始化LaiYu液体处理设备后端
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
"""
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
|
||||
super().__init__()
|
||||
else:
|
||||
# 模拟版本接受 name 参数
|
||||
super().__init__(name)
|
||||
|
||||
self.name = name
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.is_connected = False
|
||||
self.device_info = {
|
||||
"name": "LaiYu液体处理设备",
|
||||
"version": "1.0.0",
|
||||
"manufacturer": "LaiYu",
|
||||
"model": "LaiYu_Liquid_Handler"
|
||||
}
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到LaiYu液体处理设备
|
||||
|
||||
Returns:
|
||||
bool: 连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在连接到LaiYu液体处理设备...")
|
||||
# 这里应该实现实际的设备连接逻辑
|
||||
# 目前返回模拟连接成功
|
||||
self.is_connected = True
|
||||
self.logger.info("成功连接到LaiYu液体处理设备")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
"""
|
||||
断开与LaiYu液体处理设备的连接
|
||||
|
||||
Returns:
|
||||
bool: 断开连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
|
||||
# 这里应该实现实际的设备断开连接逻辑
|
||||
self.is_connected = False
|
||||
self.logger.info("成功断开与LaiYu液体处理设备的连接")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
|
||||
return False
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
"""
|
||||
检查设备是否已连接
|
||||
|
||||
Returns:
|
||||
bool: 设备是否已连接
|
||||
"""
|
||||
return self.is_connected
|
||||
|
||||
def get_device_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备信息字典
|
||||
"""
|
||||
return self.device_info.copy()
|
||||
|
||||
def home_device(self) -> bool:
|
||||
"""
|
||||
设备归零操作
|
||||
|
||||
Returns:
|
||||
bool: 归零是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行归零操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info("正在执行设备归零操作...")
|
||||
# 这里应该实现实际的设备归零逻辑
|
||||
self.logger.info("设备归零操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"设备归零操作失败: {e}")
|
||||
return False
|
||||
|
||||
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
吸液操作
|
||||
|
||||
Args:
|
||||
volume: 吸液体积 (微升)
|
||||
location: 吸液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 吸液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行吸液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的吸液逻辑
|
||||
self.logger.info("吸液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"吸液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
排液操作
|
||||
|
||||
Args:
|
||||
volume: 排液体积 (微升)
|
||||
location: 排液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 排液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行排液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的排液逻辑
|
||||
self.logger.info("排液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"排液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
取枪头操作
|
||||
|
||||
Args:
|
||||
location: 枪头位置信息
|
||||
|
||||
Returns:
|
||||
bool: 取枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行取枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行取枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的取枪头逻辑
|
||||
self.logger.info("取枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"取枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def drop_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
丢弃枪头操作
|
||||
|
||||
Args:
|
||||
location: 丢弃位置信息
|
||||
|
||||
Returns:
|
||||
bool: 丢弃枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行丢弃枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的丢弃枪头逻辑
|
||||
self.logger.info("丢弃枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"丢弃枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def move_to(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
移动到指定位置
|
||||
|
||||
Args:
|
||||
location: 目标位置信息
|
||||
|
||||
Returns:
|
||||
bool: 移动是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行移动操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在移动到位置: {location}")
|
||||
# 这里应该实现实际的移动逻辑
|
||||
self.logger.info("移动操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"移动操作失败: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备状态
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备状态信息
|
||||
"""
|
||||
return {
|
||||
"connected": self.is_connected,
|
||||
"device_info": self.device_info,
|
||||
"status": "ready" if self.is_connected else "disconnected"
|
||||
}
|
||||
|
||||
# PyLabRobot 抽象方法实现
|
||||
def stop(self):
|
||||
"""停止所有操作"""
|
||||
self.logger.info("停止所有操作")
|
||||
pass
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
"""返回通道数量"""
|
||||
return 1 # 单通道移液器
|
||||
|
||||
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
|
||||
"""检查是否可以拾取吸头"""
|
||||
return True # 简化实现,总是返回True
|
||||
|
||||
def pick_up_tips(self, tip_rack, tip_positions):
|
||||
"""拾取多个吸头"""
|
||||
self.logger.info(f"拾取吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def drop_tips(self, tip_rack, tip_positions):
|
||||
"""丢弃多个吸头"""
|
||||
self.logger.info(f"丢弃吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def pick_up_tips96(self, tip_rack):
|
||||
"""拾取96个吸头"""
|
||||
self.logger.info("拾取96个吸头")
|
||||
pass
|
||||
|
||||
def drop_tips96(self, tip_rack):
|
||||
"""丢弃96个吸头"""
|
||||
self.logger.info("丢弃96个吸头")
|
||||
pass
|
||||
|
||||
def aspirate96(self, volume, plate, well_positions):
|
||||
"""96通道吸液"""
|
||||
self.logger.info(f"96通道吸液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def dispense96(self, volume, plate, well_positions):
|
||||
"""96通道排液"""
|
||||
self.logger.info(f"96通道排液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def pick_up_resource(self, resource, location):
|
||||
"""拾取资源"""
|
||||
self.logger.info(f"拾取资源: {resource}")
|
||||
pass
|
||||
|
||||
def drop_resource(self, resource, location):
|
||||
"""放置资源"""
|
||||
self.logger.info(f"放置资源: {resource}")
|
||||
pass
|
||||
|
||||
def move_picked_up_resource(self, resource, location):
|
||||
"""移动已拾取的资源"""
|
||||
self.logger.info(f"移动资源: {resource} 到 {location}")
|
||||
pass
|
||||
|
||||
|
||||
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
|
||||
"""
|
||||
创建LaiYu液体处理设备后端实例
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidBackend: 后端实例
|
||||
"""
|
||||
return LaiYuLiquidBackend(name)
|
||||
@@ -1,385 +1,307 @@
|
||||
|
||||
import json
|
||||
"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式
|
||||
|
||||
硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致:
|
||||
1. XYZController(auto_connect=True) — 先开串口
|
||||
2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁
|
||||
3. home_all_axes() + pipette.initialize()
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pylabrobot.liquid_handling.backends.backend import (
|
||||
LiquidHandlerBackend,
|
||||
)
|
||||
from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend
|
||||
from pylabrobot.liquid_handling.standard import (
|
||||
Drop,
|
||||
DropTipRack,
|
||||
MultiHeadAspirationContainer,
|
||||
MultiHeadAspirationPlate,
|
||||
MultiHeadDispenseContainer,
|
||||
MultiHeadDispensePlate,
|
||||
Pickup,
|
||||
PickupTipRack,
|
||||
ResourceDrop,
|
||||
ResourceMove,
|
||||
ResourcePickup,
|
||||
SingleChannelAspiration,
|
||||
SingleChannelDispense,
|
||||
Drop,
|
||||
DropTipRack,
|
||||
MultiHeadAspirationContainer,
|
||||
MultiHeadAspirationPlate,
|
||||
MultiHeadDispenseContainer,
|
||||
MultiHeadDispensePlate,
|
||||
Pickup,
|
||||
PickupTipRack,
|
||||
ResourceDrop,
|
||||
ResourceMove,
|
||||
ResourcePickup,
|
||||
SingleChannelAspiration,
|
||||
SingleChannelDispense,
|
||||
)
|
||||
from pylabrobot.resources import Resource, Tip
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import JointState
|
||||
import time
|
||||
from rclpy.action import ActionClient
|
||||
from unilabos_msgs.action import SendCmd
|
||||
import re
|
||||
from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController
|
||||
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import (
|
||||
PipetteController,
|
||||
TipStatus,
|
||||
)
|
||||
|
||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
|
||||
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
"""Chatter box backend for device-free testing. Prints out all operations."""
|
||||
"""LaiYu 硬件后端 — PLR Backend 接口实现"""
|
||||
|
||||
_pip_length = 5
|
||||
_vol_length = 8
|
||||
_resource_length = 20
|
||||
_offset_length = 16
|
||||
_flow_rate_length = 10
|
||||
_blowout_length = 10
|
||||
_lld_z_length = 10
|
||||
_kwargs_length = 15
|
||||
_tip_type_length = 12
|
||||
_max_volume_length = 16
|
||||
_fitting_depth_length = 20
|
||||
_tip_length_length = 16
|
||||
# _pickup_method_length = 20
|
||||
_filter_length = 10
|
||||
def __init__(
|
||||
self,
|
||||
num_channels: int = 1,
|
||||
tip_length: float = 0,
|
||||
total_height: float = 310,
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baudrate: int = 115200,
|
||||
pipette_address: int = 4,
|
||||
):
|
||||
super().__init__()
|
||||
self._num_channels = num_channels
|
||||
self.tip_length = tip_length
|
||||
self.total_height = total_height
|
||||
|
||||
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
|
||||
"""Initialize a chatter box backend."""
|
||||
super().__init__()
|
||||
self._num_channels = num_channels
|
||||
self.tip_length = tip_length
|
||||
self.total_height = total_height
|
||||
# rclpy.init()
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
self.hardware_interface = PipetteController(port=port)
|
||||
# 保存配置,延迟到 setup() 再创建硬件对象
|
||||
self._port = port
|
||||
self._baudrate = baudrate
|
||||
self._pipette_address = pipette_address
|
||||
|
||||
async def setup(self):
|
||||
# self.joint_state_publisher = JointStatePublisher()
|
||||
# self.hardware_interface.xyz_controller.connect_device()
|
||||
# self.hardware_interface.xyz_controller.home_all_axes()
|
||||
await super().setup()
|
||||
self.hardware_interface.connect()
|
||||
self.hardware_interface.initialize()
|
||||
self._xyz: Optional[XYZController] = None
|
||||
self._pipette_ctrl: Optional[PipetteController] = None
|
||||
self._ros_node = None
|
||||
|
||||
print("Setting up the liquid handler.")
|
||||
# ------------------------------------------------------------------ lifecycle
|
||||
|
||||
async def stop(self):
|
||||
print("Stopping the liquid handler.")
|
||||
def post_init(self, ros_node):
|
||||
"""接收 ROS 节点引用(由 Handler.post_init 调用)"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {**super().serialize(), "num_channels": self.num_channels}
|
||||
async def setup(self):
|
||||
"""按路径 B 顺序初始化硬件"""
|
||||
await super().setup()
|
||||
|
||||
def pipette_aspirate(self, volume: float, flow_rate: float):
|
||||
# 1. XYZ 先开串口
|
||||
self._xyz = XYZController(
|
||||
port=self._port,
|
||||
baudrate=self._baudrate,
|
||||
auto_connect=True,
|
||||
)
|
||||
if not self._xyz.is_connected:
|
||||
raise RuntimeError("XYZ 控制器连接失败")
|
||||
|
||||
self.hardware_interface.pipette.set_max_speed(flow_rate)
|
||||
res = self.hardware_interface.pipette.aspirate(volume=volume)
|
||||
|
||||
if not res:
|
||||
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
|
||||
return
|
||||
# 2. PipetteController 共享 XYZ 串口
|
||||
self._pipette_ctrl = PipetteController(
|
||||
port=self._port,
|
||||
address=self._pipette_address,
|
||||
)
|
||||
self._pipette_ctrl.connect_shared(
|
||||
serial_conn=self._xyz.serial_conn,
|
||||
serial_lock=self._xyz.serial_lock,
|
||||
xyz_controller=self._xyz,
|
||||
)
|
||||
|
||||
self.hardware_interface.current_volume += volume
|
||||
# 3. 回零 + 移液器初始化
|
||||
self._xyz.home_all_axes()
|
||||
self._pipette_ctrl.initialize()
|
||||
|
||||
def pipette_dispense(self, volume: float, flow_rate: float):
|
||||
logger.info("LaiYu 后端硬件初始化完成")
|
||||
|
||||
self.hardware_interface.pipette.set_max_speed(flow_rate)
|
||||
res = self.hardware_interface.pipette.dispense(volume=volume)
|
||||
if not res:
|
||||
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
|
||||
return
|
||||
self.hardware_interface.current_volume -= volume
|
||||
async def stop(self):
|
||||
"""正确断开硬件"""
|
||||
try:
|
||||
if self._pipette_ctrl:
|
||||
self._pipette_ctrl.disconnect_shared()
|
||||
if self._xyz:
|
||||
self._xyz.disconnect()
|
||||
logger.info("LaiYu 后端硬件已断开")
|
||||
except Exception as e:
|
||||
logger.error(f"停止后端失败: {e}")
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
return self._num_channels
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
async def assigned_resource_callback(self, resource: Resource):
|
||||
print(f"Resource {resource.name} was assigned to the liquid handler.")
|
||||
def _plr_to_machine_coords(self, resource, offset):
|
||||
"""PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)"""
|
||||
coordinate = resource.get_absolute_location(x="c", y="c")
|
||||
x = coordinate.x + offset.x
|
||||
y = coordinate.y + offset.y
|
||||
z_plr = coordinate.z + offset.z
|
||||
return x, -y, self.total_height - (z_plr + self.tip_length)
|
||||
|
||||
async def unassigned_resource_callback(self, name: str):
|
||||
print(f"Resource {name} was unassigned from the liquid handler.")
|
||||
def _pipette_aspirate(self, volume: float, flow_rate: float):
|
||||
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
|
||||
res = self._pipette_ctrl.pipette.aspirate(volume=volume)
|
||||
if not res:
|
||||
logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}")
|
||||
return
|
||||
self._pipette_ctrl.current_volume += volume
|
||||
|
||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||
print("Picking up tips:")
|
||||
# print(ops.tip)
|
||||
header = (
|
||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
||||
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
||||
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
||||
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
||||
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
||||
)
|
||||
# print(header)
|
||||
def _pipette_dispense(self, volume: float, flow_rate: float):
|
||||
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
|
||||
res = self._pipette_ctrl.pipette.dispense(volume=volume)
|
||||
if not res:
|
||||
logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}")
|
||||
return
|
||||
self._pipette_ctrl.current_volume -= volume
|
||||
|
||||
for op, channel in zip(ops, use_channels):
|
||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{channel}: "
|
||||
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
||||
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
||||
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
||||
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
||||
)
|
||||
# print(row)
|
||||
# print(op.resource.get_absolute_location())
|
||||
|
||||
self.tip_length = ops[0].tip.total_tip_length
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
offset_xyz = ops[0].offset
|
||||
x = coordinate.x + offset_xyz.x
|
||||
y = coordinate.y + offset_xyz.y
|
||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||
# print("moving")
|
||||
self.hardware_interface._update_tip_status()
|
||||
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
||||
print("已有枪头,无需重复拾取")
|
||||
return
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
|
||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
|
||||
# goback()
|
||||
# ------------------------------------------------------------------ properties
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {**super().serialize(), "num_channels": self.num_channels}
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
return self._num_channels
|
||||
|
||||
# ------------------------------------------------------------------ resource callbacks
|
||||
|
||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||
print("Dropping tips:")
|
||||
header = (
|
||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
||||
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
||||
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
||||
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
||||
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
||||
)
|
||||
# print(header)
|
||||
async def assigned_resource_callback(self, resource: Resource):
|
||||
logger.info(f"Resource {resource.name} was assigned to the liquid handler.")
|
||||
|
||||
for op, channel in zip(ops, use_channels):
|
||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{channel}: "
|
||||
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
||||
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
||||
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
||||
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
||||
)
|
||||
# print(row)
|
||||
async def unassigned_resource_callback(self, name: str):
|
||||
logger.info(f"Resource {name} was unassigned from the liquid handler.")
|
||||
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
offset_xyz = ops[0].offset
|
||||
x = coordinate.x + offset_xyz.x
|
||||
y = coordinate.y + offset_xyz.y
|
||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
|
||||
# print(x, y, z)
|
||||
# print("moving")
|
||||
self.hardware_interface._update_tip_status()
|
||||
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
|
||||
print("无枪头,无需丢弃")
|
||||
return
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.hardware_interface.eject_tip
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
||||
# ------------------------------------------------------------------ pick_up_tips
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
ops: List[SingleChannelAspiration],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
print("Aspirating:")
|
||||
header = (
|
||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
||||
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
||||
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
||||
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
||||
# f"{'liquids':<20}" # TODO: add liquids
|
||||
)
|
||||
for key in backend_kwargs:
|
||||
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
|
||||
# print(header)
|
||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||
tip = ops[0].tip
|
||||
self.tip_length = tip.total_tip_length
|
||||
x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<15}"
|
||||
# print(row)
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
offset_xyz = ops[0].offset
|
||||
x = coordinate.x + offset_xyz.x
|
||||
y = coordinate.y + offset_xyz.y
|
||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||
# print(x, y, z)
|
||||
# print("moving")
|
||||
self._pipette_ctrl._update_tip_status()
|
||||
if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED:
|
||||
logger.warning("已有枪头,无需重复拾取")
|
||||
return
|
||||
|
||||
# 判断枪头是否存在
|
||||
self.hardware_interface._update_tip_status()
|
||||
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
||||
print("无枪头,无法吸液")
|
||||
return
|
||||
# 判断吸液量是否超过枪头容量
|
||||
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
|
||||
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
|
||||
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
|
||||
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
|
||||
return
|
||||
try:
|
||||
# 1. 移到枪头正上方
|
||||
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200)
|
||||
# 2. 下压到套枪头深度(fitting_depth 是枪头套入长度)
|
||||
z_pickup = z_top + tip.fitting_depth
|
||||
self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100)
|
||||
# 3. 退回安全高度
|
||||
self._xyz.move_to_work_coord_safe(
|
||||
z=self._xyz.machine_config.safe_z_height, speed=100
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"pick_up_tips 移动失败: {e}")
|
||||
raise
|
||||
|
||||
# 移动到吸液位置
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
# ------------------------------------------------------------------ drop_tips
|
||||
|
||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||
z -= 20 # 额外下移补偿
|
||||
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
||||
if blow_out_air_volume >0:
|
||||
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||
self._pipette_ctrl._update_tip_status()
|
||||
if self._pipette_ctrl.tip_status == TipStatus.NO_TIP:
|
||||
logger.warning("无枪头,无需丢弃")
|
||||
return
|
||||
|
||||
try:
|
||||
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||
self._pipette_ctrl.eject_tip() # 修复: 原来缺少 ()
|
||||
self._xyz.move_to_work_coord_safe(
|
||||
z=self._xyz.machine_config.safe_z_height
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"drop_tips 失败: {e}")
|
||||
raise
|
||||
|
||||
# ------------------------------------------------------------------ aspirate
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
ops: List[SingleChannelAspiration],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
ops: List[SingleChannelDispense],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
# print("Dispensing:")
|
||||
header = (
|
||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
||||
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
||||
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
||||
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
||||
# f"{'liquids':<20}" # TODO: add liquids
|
||||
)
|
||||
for key in backend_kwargs:
|
||||
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
|
||||
# print(header)
|
||||
self._pipette_ctrl._update_tip_status()
|
||||
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
|
||||
raise RuntimeError("无枪头,无法吸液")
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
|
||||
# print(row)
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
offset_xyz = ops[0].offset
|
||||
x = coordinate.x + offset_xyz.x
|
||||
y = coordinate.y + offset_xyz.y
|
||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||
# print(x, y, z)
|
||||
# print("moving")
|
||||
flow_rate = backend_kwargs.get("flow_rate", 500)
|
||||
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
|
||||
|
||||
# 判断枪头是否存在
|
||||
self.hardware_interface._update_tip_status()
|
||||
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
||||
print("无枪头,无法排液")
|
||||
return
|
||||
# 判断排液量是否超过枪头容量
|
||||
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
|
||||
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
|
||||
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
|
||||
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
|
||||
return
|
||||
if (
|
||||
self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume
|
||||
> self._pipette_ctrl.max_volume
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"吸液量超过枪头容量: "
|
||||
f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}"
|
||||
)
|
||||
|
||||
|
||||
# 移动到排液位置
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||
self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
|
||||
self._xyz.move_to_work_coord_safe(
|
||||
z=self._xyz.machine_config.safe_z_height
|
||||
)
|
||||
if blow_out_air_volume > 0:
|
||||
self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
||||
if blow_out_air_volume > 0:
|
||||
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
|
||||
# ------------------------------------------------------------------ dispense
|
||||
|
||||
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||
print(f"Picking up tips from {pickup.resource.name}.")
|
||||
async def dispense(
|
||||
self,
|
||||
ops: List[SingleChannelDispense],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||
|
||||
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||
print(f"Dropping tips to {drop.resource.name}.")
|
||||
self._pipette_ctrl._update_tip_status()
|
||||
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
|
||||
raise RuntimeError("无枪头,无法排液")
|
||||
|
||||
async def aspirate96(
|
||||
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||
):
|
||||
if isinstance(aspiration, MultiHeadAspirationPlate):
|
||||
resource = aspiration.wells[0].parent
|
||||
else:
|
||||
resource = aspiration.container
|
||||
print(f"Aspirating {aspiration.volume} from {resource}.")
|
||||
flow_rate = backend_kwargs.get("flow_rate", 500)
|
||||
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
|
||||
|
||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||
if isinstance(dispense, MultiHeadDispensePlate):
|
||||
resource = dispense.wells[0].parent
|
||||
else:
|
||||
resource = dispense.container
|
||||
print(f"Dispensing {dispense.volume} to {resource}.")
|
||||
if (
|
||||
self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"排液量超过当前体积: "
|
||||
f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0"
|
||||
)
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||
print(f"Picking up resource: {pickup}")
|
||||
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||
self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
|
||||
async def move_picked_up_resource(self, move: ResourceMove):
|
||||
print(f"Moving picked up resource: {move}")
|
||||
self._xyz.move_to_work_coord_safe(
|
||||
z=self._xyz.machine_config.safe_z_height
|
||||
)
|
||||
if blow_out_air_volume > 0:
|
||||
self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop):
|
||||
print(f"Dropping resource: {drop}")
|
||||
# ------------------------------------------------------------------ 96-channel stubs
|
||||
|
||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||
return True
|
||||
|
||||
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||
logger.info(f"Picking up tips from {pickup.resource.name}.")
|
||||
|
||||
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||
logger.info(f"Dropping tips to {drop.resource.name}.")
|
||||
|
||||
async def aspirate96(
|
||||
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||
):
|
||||
if isinstance(aspiration, MultiHeadAspirationPlate):
|
||||
resource = aspiration.wells[0].parent
|
||||
else:
|
||||
resource = aspiration.container
|
||||
logger.info(f"Aspirating {aspiration.volume} from {resource}.")
|
||||
|
||||
async def dispense96(
|
||||
self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]
|
||||
):
|
||||
if isinstance(dispense, MultiHeadDispensePlate):
|
||||
resource = dispense.wells[0].parent
|
||||
else:
|
||||
resource = dispense.container
|
||||
logger.info(f"Dispensing {dispense.volume} to {resource}.")
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||
logger.info(f"Picking up resource: {pickup}")
|
||||
|
||||
async def move_picked_up_resource(self, move: ResourceMove):
|
||||
logger.info(f"Moving picked up resource: {move}")
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop):
|
||||
logger.info(f"Dropping resource: {drop}")
|
||||
|
||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||
return True
|
||||
|
||||
@@ -5,21 +5,16 @@
|
||||
封装SOPA移液器的高级控制功能
|
||||
"""
|
||||
|
||||
# 添加项目根目录到Python路径以解决模块导入问题
|
||||
import sys
|
||||
import os
|
||||
from tkinter import N
|
||||
|
||||
_current_file = os.path.abspath(__file__)
|
||||
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file)))))
|
||||
if _project_root not in sys.path:
|
||||
sys.path.insert(0, _project_root)
|
||||
|
||||
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
|
||||
|
||||
# 无论如何都添加项目根目录到路径
|
||||
current_file = os.path.abspath(__file__)
|
||||
# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py
|
||||
# 向上5级到 .../Uni-Lab-OS
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
|
||||
# 强制添加项目根目录到sys.path的开头
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
@@ -153,7 +148,7 @@ class PipetteController:
|
||||
logger.error("移液器连接失败")
|
||||
return False
|
||||
logger.info("移液器连接成功")
|
||||
|
||||
|
||||
# 连接XYZ步进电机控制器(如果提供了端口)
|
||||
if self.xyz_port != self.pipette_port:
|
||||
try:
|
||||
@@ -172,24 +167,62 @@ class PipetteController:
|
||||
try:
|
||||
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
|
||||
self.xyz_controller.serial_conn = self.pipette.serial_port
|
||||
self.xyz_controller.serial_lock = self.pipette.lock
|
||||
self.xyz_controller.is_connected = True
|
||||
logger.info("XYZ控制器与移液器共享串口和互斥锁")
|
||||
except Exception as e:
|
||||
logger.info("未配置XYZ步进电机端口,跳过运动控制器连接")
|
||||
|
||||
logger.warning(f"共享端口 XYZ 控制器创建失败: {e}")
|
||||
self.xyz_controller = None
|
||||
self.xyz_connected = False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"设备连接失败: {e}")
|
||||
return False
|
||||
|
||||
def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool:
|
||||
"""使用已连接的串口和XYZ控制器(路径 B 模式:XYZ 先开串口,移液器共享)
|
||||
|
||||
Args:
|
||||
serial_conn: 已打开的串口连接(来自 XYZController)
|
||||
serial_lock: 串口互斥锁(来自 XYZController)
|
||||
xyz_controller: 已连接的 XYZController 实例
|
||||
"""
|
||||
try:
|
||||
self.pipette.serial_port = serial_conn
|
||||
self.pipette.lock = serial_lock
|
||||
self.pipette.is_connected = True
|
||||
|
||||
self.xyz_controller = xyz_controller
|
||||
self.xyz_connected = True
|
||||
|
||||
logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"connect_shared 失败: {e}")
|
||||
return False
|
||||
|
||||
def disconnect_shared(self) -> None:
|
||||
"""释放共享串口引用(与 connect_shared 对称)。
|
||||
|
||||
注意:不关闭串口本身,串口由 XYZController 负责关闭。
|
||||
"""
|
||||
try:
|
||||
self.pipette.serial_port = None
|
||||
self.pipette.lock = None
|
||||
self.pipette.is_connected = False
|
||||
self.xyz_controller = None
|
||||
self.xyz_connected = False
|
||||
logger.info("移液控制器已释放共享串口引用")
|
||||
except Exception as e:
|
||||
logger.error(f"disconnect_shared 失败: {e}")
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""初始化移液器"""
|
||||
try:
|
||||
if self.pipette.initialize():
|
||||
logger.info("移液器初始化成功")
|
||||
# 检查枪头状态
|
||||
self._update_tip_status()
|
||||
self.xyz_controller.home_all_axes()
|
||||
self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
@@ -198,56 +231,58 @@ class PipetteController:
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
# 断开移液器连接
|
||||
if self.xyz_controller and self.xyz_connected:
|
||||
if self.xyz_port != self.pipette_port:
|
||||
try:
|
||||
self.xyz_controller.disconnect()
|
||||
logger.info("XYZ 步进电机已断开")
|
||||
except Exception as e:
|
||||
logger.error(f"断开 XYZ 步进电机失败: {e}")
|
||||
else:
|
||||
self.xyz_controller.serial_conn = None
|
||||
self.xyz_connected = False
|
||||
self.xyz_controller = None
|
||||
|
||||
self.pipette.disconnect()
|
||||
logger.info("移液器已断开")
|
||||
|
||||
# 断开 XYZ 步进电机连接
|
||||
if self.xyz_controller and self.xyz_connected:
|
||||
try:
|
||||
self.xyz_controller.disconnect()
|
||||
self.xyz_connected = False
|
||||
logger.info("XYZ 步进电机已断开")
|
||||
except Exception as e:
|
||||
logger.error(f"断开 XYZ 步进电机失败: {e}")
|
||||
|
||||
def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
|
||||
"""
|
||||
检查 XYZ 轴移动的安全性
|
||||
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
target_position: 目标位置(步数)
|
||||
|
||||
|
||||
Returns:
|
||||
是否安全
|
||||
"""
|
||||
try:
|
||||
# 获取当前电机状态
|
||||
motor_position = self.xyz_controller.get_motor_status(axis)
|
||||
|
||||
|
||||
# 检查电机状态是否正常 (不是碰撞停止或限位停止)
|
||||
if motor_position.status in [MotorStatus.COLLISION_STOP,
|
||||
MotorStatus.FORWARD_LIMIT_STOP,
|
||||
if motor_position.status in [MotorStatus.COLLISION_STOP,
|
||||
MotorStatus.FORWARD_LIMIT_STOP,
|
||||
MotorStatus.REVERSE_LIMIT_STOP]:
|
||||
logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}")
|
||||
return False
|
||||
|
||||
|
||||
# 检查位置限制 (扩大安全范围以适应实际硬件)
|
||||
# 步进电机的位置范围通常很大,这里设置更合理的范围
|
||||
if target_position < -500000 or target_position > 500000:
|
||||
logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}")
|
||||
return False
|
||||
|
||||
|
||||
# 检查移动距离是否过大 (单次移动不超过 20000 步,约12mm)
|
||||
current_position = motor_position.steps
|
||||
move_distance = abs(target_position - current_position)
|
||||
if move_distance > 20000:
|
||||
logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}步")
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"安全检查失败: {e}")
|
||||
return False
|
||||
@@ -255,48 +290,48 @@ class PipetteController:
|
||||
def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool:
|
||||
"""
|
||||
Z轴相对移动
|
||||
|
||||
|
||||
Args:
|
||||
distance_mm: 移动距离(mm),正值向下,负值向上
|
||||
speed: 移动速度(rpm)
|
||||
acceleration: 加速度(rpm/s)
|
||||
|
||||
|
||||
Returns:
|
||||
移动是否成功
|
||||
"""
|
||||
if not self.xyz_controller or not self.xyz_connected:
|
||||
logger.error("XYZ 步进电机未连接,无法执行移动")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
# 参数验证
|
||||
if abs(distance_mm) > 15.0:
|
||||
logger.error(f"移动距离过大: {distance_mm}mm,最大允许15mm")
|
||||
return False
|
||||
|
||||
|
||||
if speed < 100 or speed > 5000:
|
||||
logger.error(f"速度参数无效: {speed}rpm,范围应为100-5000")
|
||||
return False
|
||||
|
||||
|
||||
# 获取当前 Z 轴位置
|
||||
current_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
|
||||
current_z_position = current_status.steps
|
||||
|
||||
|
||||
# 计算移动距离对应的步数 (1mm = 1638.4步)
|
||||
mm_to_steps = 1638.4
|
||||
move_distance_steps = int(distance_mm * mm_to_steps)
|
||||
|
||||
|
||||
# 计算目标位置
|
||||
target_z_position = current_z_position + move_distance_steps
|
||||
|
||||
|
||||
# 安全检查
|
||||
if not self._check_xyz_safety(MotorAxis.Z, target_z_position):
|
||||
logger.error("Z轴移动安全检查失败")
|
||||
return False
|
||||
|
||||
|
||||
logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)")
|
||||
logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}步")
|
||||
|
||||
|
||||
# 执行移动
|
||||
success = self.xyz_controller.move_to_position(
|
||||
axis=MotorAxis.Z,
|
||||
@@ -305,28 +340,28 @@ class PipetteController:
|
||||
acceleration=acceleration,
|
||||
precision=50
|
||||
)
|
||||
|
||||
|
||||
if not success:
|
||||
logger.error("Z轴移动命令发送失败")
|
||||
return False
|
||||
|
||||
|
||||
# 等待移动完成
|
||||
if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0):
|
||||
logger.error("Z轴移动超时")
|
||||
return False
|
||||
|
||||
|
||||
# 验证移动结果
|
||||
final_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
|
||||
final_position = final_status.steps
|
||||
position_error = abs(final_position - target_z_position)
|
||||
|
||||
|
||||
logger.info(f"Z轴移动完成,最终位置: {final_position}步,误差: {position_error}步")
|
||||
|
||||
|
||||
if position_error > 100:
|
||||
logger.warning(f"Z轴位置误差较大: {position_error}步")
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
except ModbusException as e:
|
||||
logger.error(f"Modbus通信错误: {e}")
|
||||
return False
|
||||
@@ -337,21 +372,20 @@ class PipetteController:
|
||||
def emergency_stop(self) -> bool:
|
||||
"""
|
||||
紧急停止所有运动
|
||||
|
||||
|
||||
Returns:
|
||||
停止是否成功
|
||||
"""
|
||||
success = True
|
||||
|
||||
# 停止移液器操作
|
||||
|
||||
try:
|
||||
if self.pipette and self.connected:
|
||||
# 这里可以添加移液器的紧急停止逻辑
|
||||
if self.pipette and self.pipette.is_connected:
|
||||
self.pipette.emergency_stop()
|
||||
logger.info("移液器紧急停止")
|
||||
except Exception as e:
|
||||
logger.error(f"移液器紧急停止失败: {e}")
|
||||
success = False
|
||||
|
||||
|
||||
# 停止 XYZ 轴运动
|
||||
try:
|
||||
if self.xyz_controller and self.xyz_connected:
|
||||
@@ -360,7 +394,7 @@ class PipetteController:
|
||||
except Exception as e:
|
||||
logger.error(f"XYZ 轴紧急停止失败: {e}")
|
||||
success = False
|
||||
|
||||
|
||||
return success
|
||||
|
||||
def pickup_tip(self) -> bool:
|
||||
@@ -376,7 +410,7 @@ class PipetteController:
|
||||
return True
|
||||
|
||||
logger.info("开始装载枪头 - Z轴向下移动10mm")
|
||||
|
||||
|
||||
# 使用相对移动方法,向下移动10mm
|
||||
if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500):
|
||||
# 更新枪头状态
|
||||
@@ -688,31 +722,31 @@ class PipetteController:
|
||||
if __name__ == "__main__":
|
||||
# 配置日志
|
||||
import logging
|
||||
|
||||
|
||||
# 设置日志级别
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
|
||||
def interactive_test():
|
||||
"""交互式测试模式 - 适用于已连接的设备"""
|
||||
print("\n" + "=" * 60)
|
||||
print("🧪 移液器交互式测试模式")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
# 获取用户输入的连接参数
|
||||
print("\n📡 设备连接配置:")
|
||||
port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
|
||||
address_input = input("请输入移液器设备地址 (默认: 4): ").strip()
|
||||
address = int(address_input) if address_input else 4
|
||||
|
||||
|
||||
# 询问是否连接 XYZ 步进电机控制器
|
||||
xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower()
|
||||
xyz_port = None
|
||||
if xyz_enable not in ['n', 'no']:
|
||||
xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
|
||||
|
||||
|
||||
try:
|
||||
# 创建移液控制器实例
|
||||
if xyz_port:
|
||||
@@ -721,21 +755,21 @@ if __name__ == "__main__":
|
||||
else:
|
||||
print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...")
|
||||
pipette = PipetteController(port=port, address=address)
|
||||
|
||||
|
||||
# 连接设备
|
||||
print("\n📞 连接移液器设备...")
|
||||
if not pipette.connect():
|
||||
print("❌ 设备连接失败,请检查连接")
|
||||
return
|
||||
print("✅ 设备连接成功")
|
||||
|
||||
|
||||
# 初始化设备
|
||||
print("\n🚀 初始化设备...")
|
||||
if not pipette.initialize():
|
||||
print("❌ 设备初始化失败")
|
||||
return
|
||||
print("✅ 设备初始化成功")
|
||||
|
||||
|
||||
# 交互式菜单
|
||||
while True:
|
||||
print("\n" + "=" * 50)
|
||||
@@ -755,9 +789,9 @@ if __name__ == "__main__":
|
||||
print("99. 🚨 紧急停止")
|
||||
print("0. 🚪 退出程序")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
choice = input("\n请选择操作 (0-12, 99): ").strip()
|
||||
|
||||
|
||||
if choice == "0":
|
||||
print("\n👋 退出程序...")
|
||||
break
|
||||
@@ -773,7 +807,7 @@ if __name__ == "__main__":
|
||||
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
|
||||
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
|
||||
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
|
||||
|
||||
|
||||
elif choice == "2":
|
||||
# 装载枪头
|
||||
print("\n🔧 装载枪头...")
|
||||
@@ -781,14 +815,14 @@ if __name__ == "__main__":
|
||||
print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)")
|
||||
else:
|
||||
print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载")
|
||||
|
||||
|
||||
if pipette.pickup_tip():
|
||||
print("✅ 枪头装载成功")
|
||||
if pipette.xyz_connected:
|
||||
print("📍 Z 轴已移动到装载位置")
|
||||
else:
|
||||
print("❌ 枪头装载失败")
|
||||
|
||||
|
||||
elif choice == "3":
|
||||
# 弹出枪头
|
||||
print("\n🗑️ 弹出枪头...")
|
||||
@@ -796,7 +830,7 @@ if __name__ == "__main__":
|
||||
print("✅ 枪头弹出成功")
|
||||
else:
|
||||
print("❌ 枪头弹出失败")
|
||||
|
||||
|
||||
elif choice == "4":
|
||||
# 吸液操作
|
||||
try:
|
||||
@@ -810,7 +844,7 @@ if __name__ == "__main__":
|
||||
print("❌ 吸液失败")
|
||||
except ValueError:
|
||||
print("❌ 请输入有效的数字")
|
||||
|
||||
|
||||
elif choice == "5":
|
||||
# 排液操作
|
||||
try:
|
||||
@@ -824,7 +858,7 @@ if __name__ == "__main__":
|
||||
print("❌ 排液失败")
|
||||
except ValueError:
|
||||
print("❌ 请输入有效的数字")
|
||||
|
||||
|
||||
elif choice == "6":
|
||||
# 混合操作
|
||||
try:
|
||||
@@ -838,7 +872,7 @@ if __name__ == "__main__":
|
||||
print("❌ 混合失败")
|
||||
except ValueError:
|
||||
print("❌ 请输入有效的数字")
|
||||
|
||||
|
||||
elif choice == "7":
|
||||
# 液体转移
|
||||
try:
|
||||
@@ -846,7 +880,7 @@ if __name__ == "__main__":
|
||||
source = input("源孔位 (可选, 如A1): ").strip() or None
|
||||
dest = input("目标孔位 (可选, 如B1): ").strip() or None
|
||||
new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n'
|
||||
|
||||
|
||||
print(f"\n🔄 执行液体转移 ({volume}ul)...")
|
||||
if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip):
|
||||
print("✅ 液体转移完成")
|
||||
@@ -854,7 +888,7 @@ if __name__ == "__main__":
|
||||
print("❌ 液体转移失败")
|
||||
except ValueError:
|
||||
print("❌ 请输入有效的数字")
|
||||
|
||||
|
||||
elif choice == "8":
|
||||
# 设置液体类型
|
||||
print("\n🧪 可用液体类型:")
|
||||
@@ -864,16 +898,16 @@ if __name__ == "__main__":
|
||||
"3": (LiquidClass.VISCOUS, "粘稠液体"),
|
||||
"4": (LiquidClass.VOLATILE, "挥发性液体")
|
||||
}
|
||||
|
||||
|
||||
for key, (liquid_class, description) in liquid_options.items():
|
||||
print(f" {key}. {description}")
|
||||
|
||||
|
||||
liquid_choice = input("请选择液体类型 (1-4): ").strip()
|
||||
if liquid_choice in liquid_options:
|
||||
liquid_class, description = liquid_options[liquid_choice]
|
||||
pipette.set_liquid_class(liquid_class)
|
||||
print(f"✅ 液体类型设置为: {description}")
|
||||
|
||||
|
||||
# 显示参数
|
||||
params = pipette.liquid_params
|
||||
print(f"📋 参数设置:")
|
||||
@@ -883,7 +917,7 @@ if __name__ == "__main__":
|
||||
print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}")
|
||||
else:
|
||||
print("❌ 无效选择")
|
||||
|
||||
|
||||
elif choice == "9":
|
||||
# 自定义参数
|
||||
try:
|
||||
@@ -892,19 +926,19 @@ if __name__ == "__main__":
|
||||
dispense_speed = input("排液速度 (默认800): ").strip()
|
||||
air_gap = input("空气间隙 (ul, 默认10.0): ").strip()
|
||||
pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y'
|
||||
|
||||
|
||||
custom_params = LiquidParameters(
|
||||
aspirate_speed=int(aspirate_speed) if aspirate_speed else 500,
|
||||
dispense_speed=int(dispense_speed) if dispense_speed else 800,
|
||||
air_gap=float(air_gap) if air_gap else 10.0,
|
||||
pre_wet=pre_wet
|
||||
)
|
||||
|
||||
|
||||
pipette.set_custom_parameters(custom_params)
|
||||
print("✅ 自定义参数设置完成")
|
||||
except ValueError:
|
||||
print("❌ 请输入有效的数字")
|
||||
|
||||
|
||||
elif choice == "10":
|
||||
# 校准体积
|
||||
try:
|
||||
@@ -914,12 +948,12 @@ if __name__ == "__main__":
|
||||
print(f"✅ 校准完成,校准系数: {actual/expected:.3f}")
|
||||
except ValueError:
|
||||
print("❌ 请输入有效的数字")
|
||||
|
||||
|
||||
elif choice == "11":
|
||||
# 重置统计
|
||||
pipette.reset_statistics()
|
||||
print("✅ 统计信息已重置")
|
||||
|
||||
|
||||
elif choice == "12":
|
||||
# 液体类型测试
|
||||
print("\n🧪 液体类型参数对比:")
|
||||
@@ -929,7 +963,7 @@ if __name__ == "__main__":
|
||||
(LiquidClass.VISCOUS, "粘稠液体"),
|
||||
(LiquidClass.VOLATILE, "挥发性液体")
|
||||
]
|
||||
|
||||
|
||||
for liquid_class, description in liquid_tests:
|
||||
params = pipette.LIQUID_PARAMS[liquid_class]
|
||||
print(f"\n📋 {description} ({liquid_class.value}):")
|
||||
@@ -938,7 +972,7 @@ if __name__ == "__main__":
|
||||
print(f" 💨 空气间隙: {params.air_gap}ul")
|
||||
print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}")
|
||||
print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s")
|
||||
|
||||
|
||||
elif choice == "99":
|
||||
# 紧急停止
|
||||
print("\n🚨 执行紧急停止...")
|
||||
@@ -949,19 +983,19 @@ if __name__ == "__main__":
|
||||
else:
|
||||
print("❌ 紧急停止执行失败")
|
||||
print("⚠️ 请手动检查设备状态并采取必要措施")
|
||||
|
||||
|
||||
# 紧急停止后询问是否继续
|
||||
continue_choice = input("\n是否继续操作?(y/n): ").strip().lower()
|
||||
if continue_choice != 'y':
|
||||
print("🚪 退出程序")
|
||||
break
|
||||
|
||||
|
||||
else:
|
||||
print("❌ 无效选择,请重新输入")
|
||||
|
||||
|
||||
# 等待用户确认继续
|
||||
input("\n按回车键继续...")
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ 用户中断操作")
|
||||
except Exception as e:
|
||||
@@ -974,19 +1008,19 @@ if __name__ == "__main__":
|
||||
print("✅ 连接已断开")
|
||||
except:
|
||||
print("⚠️ 断开连接时出现问题")
|
||||
|
||||
|
||||
def demo_test():
|
||||
"""演示测试模式 - 完整功能演示"""
|
||||
print("\n" + "=" * 60)
|
||||
print("🎬 移液控制器演示测试")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
try:
|
||||
# 创建移液控制器实例
|
||||
print("1. 🔧 创建移液控制器实例...")
|
||||
pipette = PipetteController(port="/dev/ttyUSB0", address=4)
|
||||
print("✅ 移液控制器实例创建成功")
|
||||
|
||||
|
||||
# 连接设备
|
||||
print("\n2. 📞 连接移液器设备...")
|
||||
if pipette.connect():
|
||||
@@ -994,7 +1028,7 @@ if __name__ == "__main__":
|
||||
else:
|
||||
print("❌ 设备连接失败")
|
||||
return False
|
||||
|
||||
|
||||
# 初始化设备
|
||||
print("\n3. 🚀 初始化设备...")
|
||||
if pipette.initialize():
|
||||
@@ -1002,19 +1036,19 @@ if __name__ == "__main__":
|
||||
else:
|
||||
print("❌ 设备初始化失败")
|
||||
return False
|
||||
|
||||
|
||||
# 装载枪头
|
||||
print("\n4. 🔧 装载枪头...")
|
||||
if pipette.pickup_tip():
|
||||
print("✅ 枪头装载成功")
|
||||
else:
|
||||
print("❌ 枪头装载失败")
|
||||
|
||||
|
||||
# 设置液体类型
|
||||
print("\n5. 🧪 设置液体类型为血清...")
|
||||
pipette.set_liquid_class(LiquidClass.SERUM)
|
||||
print("✅ 液体类型设置完成")
|
||||
|
||||
|
||||
# 吸液操作
|
||||
print("\n6. 💧 执行吸液操作...")
|
||||
volume_to_aspirate = 100.0
|
||||
@@ -1023,7 +1057,7 @@ if __name__ == "__main__":
|
||||
print(f"📊 当前体积: {pipette.current_volume}ul")
|
||||
else:
|
||||
print("❌ 吸液失败")
|
||||
|
||||
|
||||
# 排液操作
|
||||
print("\n7. 💦 执行排液操作...")
|
||||
volume_to_dispense = 50.0
|
||||
@@ -1032,14 +1066,14 @@ if __name__ == "__main__":
|
||||
print(f"📊 剩余体积: {pipette.current_volume}ul")
|
||||
else:
|
||||
print("❌ 排液失败")
|
||||
|
||||
|
||||
# 混合操作
|
||||
print("\n8. 🌀 执行混合操作...")
|
||||
if pipette.mix(cycles=3, volume=30.0):
|
||||
print("✅ 混合完成")
|
||||
else:
|
||||
print("❌ 混合失败")
|
||||
|
||||
|
||||
# 获取状态信息
|
||||
print("\n9. 📊 获取设备状态...")
|
||||
status = pipette.get_status()
|
||||
@@ -1052,30 +1086,30 @@ if __name__ == "__main__":
|
||||
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
|
||||
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
|
||||
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
|
||||
|
||||
|
||||
# 弹出枪头
|
||||
print("\n10. 🗑️ 弹出枪头...")
|
||||
if pipette.eject_tip():
|
||||
print("✅ 枪头弹出成功")
|
||||
else:
|
||||
print("❌ 枪头弹出失败")
|
||||
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 移液控制器演示测试完成")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 测试过程中发生异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
finally:
|
||||
# 断开连接
|
||||
print("\n📞 断开连接...")
|
||||
pipette.disconnect()
|
||||
print("✅ 连接已断开")
|
||||
|
||||
|
||||
# 主程序入口
|
||||
print("🧪 移液器控制器测试程序")
|
||||
print("=" * 40)
|
||||
@@ -1083,9 +1117,9 @@ if __name__ == "__main__":
|
||||
print("2. 🎬 演示测试")
|
||||
print("0. 🚪 退出")
|
||||
print("=" * 40)
|
||||
|
||||
|
||||
mode = input("请选择测试模式 (0-2): ").strip()
|
||||
|
||||
|
||||
if mode == "1":
|
||||
interactive_test()
|
||||
elif mode == "2":
|
||||
@@ -1094,7 +1128,7 @@ if __name__ == "__main__":
|
||||
print("👋 再见!")
|
||||
else:
|
||||
print("❌ 无效选择")
|
||||
|
||||
|
||||
print("\n🎉 程序结束!")
|
||||
print("\n💡 使用说明:")
|
||||
print("1. 确保移液器硬件已正确连接")
|
||||
|
||||
@@ -13,7 +13,7 @@ from pylabrobot.liquid_handling import (
|
||||
SingleChannelDispense,
|
||||
PickupTipRack,
|
||||
DropTipRack,
|
||||
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
|
||||
MultiHeadAspirationPlate,
|
||||
)
|
||||
from pylabrobot.liquid_handling.standard import (
|
||||
MultiHeadAspirationContainer,
|
||||
@@ -41,12 +41,6 @@ class TransformXYZDeck(Deck):
|
||||
super().__init__(name, size_x, size_y, size_z)
|
||||
self.name = name
|
||||
|
||||
class TransformXYZBackend(LiquidHandlerBackend):
|
||||
def __init__(self, name: str, host: str, port: int, timeout: float):
|
||||
super().__init__()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
|
||||
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
|
||||
def __init__(self, name: str, channel_num: int):
|
||||
@@ -86,7 +80,9 @@ class TransformXYZContainer(Plate, TipRack):
|
||||
class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
support_touch_tip = False
|
||||
|
||||
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs):
|
||||
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True,
|
||||
serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4,
|
||||
total_height: float = 310, **backend_kwargs):
|
||||
# Handle case where deck is passed as a dict (from serialization)
|
||||
if isinstance(deck, dict):
|
||||
# Try to create a TransformXYZDeck from the dict
|
||||
@@ -102,11 +98,22 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
|
||||
|
||||
if simulator:
|
||||
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num)
|
||||
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num)
|
||||
else:
|
||||
self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout)
|
||||
self._unilabos_backend = UniLiquidHandlerLaiyuBackend(
|
||||
num_channels=channel_num,
|
||||
total_height=total_height,
|
||||
port=serial_port,
|
||||
baudrate=baudrate,
|
||||
pipette_address=pipette_address,
|
||||
)
|
||||
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
|
||||
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
if hasattr(self._unilabos_backend, 'post_init'):
|
||||
self._unilabos_backend.post_init(ros_node)
|
||||
|
||||
async def add_liquid(
|
||||
self,
|
||||
asp_vols: Union[List[float], float],
|
||||
@@ -128,7 +135,25 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
mix_liquid_height: Optional[float] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
pass
|
||||
return await super().add_liquid(
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
reagent_sources=reagent_sources,
|
||||
targets=targets,
|
||||
use_channels=use_channels,
|
||||
flow_rates=flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_height,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
spread=spread,
|
||||
is_96_well=is_96_well,
|
||||
delays=delays,
|
||||
mix_time=mix_time,
|
||||
mix_vol=mix_vol,
|
||||
mix_rate=mix_rate,
|
||||
mix_liquid_height=mix_liquid_height,
|
||||
none_keys=none_keys,
|
||||
)
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
@@ -142,7 +167,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
pass
|
||||
return await super().aspirate(
|
||||
resources=resources,
|
||||
vols=vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_height,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
spread=spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
@@ -156,7 +191,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
pass
|
||||
return await super().dispense(
|
||||
resources=resources,
|
||||
vols=vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_height,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
spread=spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
async def drop_tips(
|
||||
self,
|
||||
@@ -166,7 +211,13 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
allow_nonzero_volume: bool = False,
|
||||
**backend_kwargs,
|
||||
):
|
||||
pass
|
||||
return await super().drop_tips(
|
||||
tip_spots=tip_spots,
|
||||
use_channels=use_channels,
|
||||
offsets=offsets,
|
||||
allow_nonzero_volume=allow_nonzero_volume,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
async def mix(
|
||||
self,
|
||||
@@ -178,7 +229,15 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
mix_rate: Optional[float] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
pass
|
||||
return await super().mix(
|
||||
targets=targets,
|
||||
mix_time=mix_time,
|
||||
mix_vol=mix_vol,
|
||||
height_to_bottom=height_to_bottom,
|
||||
offsets=offsets,
|
||||
mix_rate=mix_rate,
|
||||
none_keys=none_keys,
|
||||
)
|
||||
|
||||
async def pick_up_tips(
|
||||
self,
|
||||
@@ -187,7 +246,12 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
offsets: Optional[List[Coordinate]] = None,
|
||||
**backend_kwargs,
|
||||
):
|
||||
pass
|
||||
return await super().pick_up_tips(
|
||||
tip_spots=tip_spots,
|
||||
use_channels=use_channels,
|
||||
offsets=offsets,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
async def transfer_liquid(
|
||||
self,
|
||||
@@ -214,5 +278,26 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
||||
delays: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
pass
|
||||
|
||||
return await super().transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=tip_racks,
|
||||
use_channels=use_channels,
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
touch_tip=touch_tip,
|
||||
liquid_height=liquid_height,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
spread=spread,
|
||||
is_96_well=is_96_well,
|
||||
mix_stage=mix_stage,
|
||||
mix_times=mix_times,
|
||||
mix_vol=mix_vol,
|
||||
mix_rate=mix_rate,
|
||||
mix_liquid_height=mix_liquid_height,
|
||||
delays=delays,
|
||||
none_keys=none_keys,
|
||||
)
|
||||
|
||||
@@ -57,6 +57,18 @@ class TransferLiquidReturn(TypedDict):
|
||||
targets: List[List[ResourceDict]]
|
||||
|
||||
|
||||
|
||||
class SetLiquidReturn(TypedDict):
|
||||
wells: list
|
||||
volumes: list
|
||||
|
||||
|
||||
class SetLiquidFromPlateReturn(TypedDict):
|
||||
plate: list
|
||||
wells: list
|
||||
volumes: list
|
||||
|
||||
|
||||
class LiquidHandlerMiddleware(LiquidHandler):
|
||||
def __init__(
|
||||
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs
|
||||
|
||||
376
unilabos/devices/motor/ZDT_X42.py
Normal file
376
unilabos/devices/motor/ZDT_X42.py
Normal file
@@ -0,0 +1,376 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ZDT X42 Closed-Loop Stepper Motor Driver
|
||||
RS485 Serial Communication via USB-Serial Converter
|
||||
|
||||
- Baudrate: 115200
|
||||
"""
|
||||
|
||||
import serial
|
||||
import time
|
||||
import threading
|
||||
import struct
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
try:
|
||||
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||
except ImportError:
|
||||
class UniversalDriver:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
def execute_command_from_outer(self, command: Any): pass
|
||||
|
||||
from serial.rs485 import RS485Settings
|
||||
|
||||
|
||||
class ZDTX42Driver(UniversalDriver):
|
||||
"""
|
||||
ZDT X42 闭环步进电机驱动器
|
||||
|
||||
支持功能:
|
||||
- 速度模式运行
|
||||
- 位置模式运行 (相对/绝对)
|
||||
- 位置读取和清零
|
||||
- 使能/禁用控制
|
||||
|
||||
通信协议:
|
||||
- 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B]
|
||||
- 响应长度根据功能码决定
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
baudrate: int = 115200,
|
||||
device_id: int = 1,
|
||||
timeout: float = 0.5,
|
||||
debug: bool = False
|
||||
):
|
||||
"""
|
||||
初始化 ZDT X42 电机驱动
|
||||
|
||||
Args:
|
||||
port: 串口设备路径
|
||||
baudrate: 波特率 (默认 115200)
|
||||
device_id: 设备地址 (1-255)
|
||||
timeout: 通信超时时间(秒)
|
||||
debug: 是否启用调试输出
|
||||
"""
|
||||
super().__init__()
|
||||
self.id = device_id
|
||||
self.debug = debug
|
||||
self.lock = threading.RLock()
|
||||
self.status = "idle" # 对应注册表中的 status (str)
|
||||
self.position = 0 # 对应注册表中的 position (int)
|
||||
|
||||
try:
|
||||
self.ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
timeout=timeout,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE
|
||||
)
|
||||
|
||||
# 启用 RS485 模式
|
||||
try:
|
||||
self.ser.rs485_mode = RS485Settings(
|
||||
rts_level_for_tx=True,
|
||||
rts_level_for_rx=False
|
||||
)
|
||||
except Exception:
|
||||
pass # RS485 模式是可选的
|
||||
|
||||
self.logger.info(
|
||||
f"ZDT X42 Motor connected: {port} "
|
||||
f"(Baud: {baudrate}, ID: {device_id})"
|
||||
)
|
||||
# 自动使能电机,确保初始状态可运动
|
||||
self.enable(True)
|
||||
|
||||
# 启动背景轮询线程,确保 position 实时刷新
|
||||
self._stop_event = threading.Event()
|
||||
self._polling_thread = threading.Thread(
|
||||
target=self._update_loop,
|
||||
name=f"ZDTPolling_{port}",
|
||||
daemon=True
|
||||
)
|
||||
self._polling_thread.start()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to open serial port {port}: {e}")
|
||||
self.ser = None
|
||||
|
||||
def _update_loop(self):
|
||||
"""背景循环读取电机位置"""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self.get_position()
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
self.logger.error(f"Polling error: {e}")
|
||||
time.sleep(1.0) # 每1秒刷新一次位置数据
|
||||
|
||||
def _send(self, func_code: int, payload: list) -> bytes:
|
||||
"""
|
||||
发送指令并接收响应
|
||||
|
||||
Args:
|
||||
func_code: 功能码
|
||||
payload: 数据负载 (list of bytes)
|
||||
|
||||
Returns:
|
||||
响应数据 (bytes)
|
||||
"""
|
||||
if not self.ser:
|
||||
self.logger.error("Serial port not available")
|
||||
return b""
|
||||
|
||||
with self.lock:
|
||||
# 清空输入缓冲区
|
||||
self.ser.reset_input_buffer()
|
||||
|
||||
# 构建消息: [ID] [功能码] [数据...] [校验位=0x6B]
|
||||
message = bytes([self.id, func_code] + payload + [0x6B])
|
||||
|
||||
# 发送
|
||||
self.ser.write(message)
|
||||
|
||||
# 根据功能码决定响应长度
|
||||
# 查询类指令返回 10 字节,控制类指令返回 4 字节
|
||||
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
|
||||
response = self.ser.read(read_len)
|
||||
|
||||
# 调试输出
|
||||
if self.debug:
|
||||
sent_hex = message.hex().upper()
|
||||
recv_hex = response.hex().upper() if response else 'TIMEOUT'
|
||||
print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}")
|
||||
|
||||
return response
|
||||
|
||||
def enable(self, on: bool = True) -> bool:
|
||||
"""
|
||||
使能/禁用电机
|
||||
|
||||
Args:
|
||||
on: True=使能(锁轴), False=禁用(松轴)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
state = 1 if on else 0
|
||||
resp = self._send(0xF3, [0xAB, state, 0])
|
||||
return len(resp) >= 4
|
||||
|
||||
def move_speed(
|
||||
self,
|
||||
speed_rpm: int,
|
||||
direction: str = "CW",
|
||||
acceleration: int = 10
|
||||
) -> bool:
|
||||
"""
|
||||
速度模式运行
|
||||
|
||||
Args:
|
||||
speed_rpm: 转速 (RPM)
|
||||
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
|
||||
acceleration: 加速度 (0-255)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
|
||||
speed_bytes = struct.pack('>H', int(speed_rpm))
|
||||
self.status = f"moving@{speed_rpm}rpm"
|
||||
resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0])
|
||||
return len(resp) >= 4
|
||||
|
||||
def move_position(
|
||||
self,
|
||||
pulses: int,
|
||||
speed_rpm: int,
|
||||
direction: str = "CW",
|
||||
acceleration: int = 10,
|
||||
absolute: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
位置模式运行
|
||||
|
||||
Args:
|
||||
pulses: 脉冲数
|
||||
speed_rpm: 转速 (RPM)
|
||||
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
|
||||
acceleration: 加速度 (0-255)
|
||||
absolute: True=绝对位置, False=相对位置
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
|
||||
speed_bytes = struct.pack('>H', int(speed_rpm))
|
||||
self.status = f"moving_to_{pulses}"
|
||||
pulse_bytes = struct.pack('>I', int(pulses))
|
||||
abs_flag = 1 if absolute else 0
|
||||
|
||||
payload = [
|
||||
dir_val,
|
||||
speed_bytes[0], speed_bytes[1],
|
||||
acceleration,
|
||||
pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3],
|
||||
abs_flag,
|
||||
0
|
||||
]
|
||||
|
||||
resp = self._send(0xFD, payload)
|
||||
return len(resp) >= 4
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""
|
||||
停止电机
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
self.status = "idle"
|
||||
resp = self._send(0xFE, [0x98, 0])
|
||||
return len(resp) >= 4
|
||||
|
||||
def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool:
|
||||
"""
|
||||
电机旋转 1/4 圈 (阻塞式)
|
||||
假设电机细分为 3200 脉冲/圈,1/4 圈 = 800 脉冲
|
||||
"""
|
||||
pulses = 800
|
||||
success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False)
|
||||
|
||||
if success:
|
||||
# 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60))
|
||||
# 1/4 rev / (RPM/60) = 15.0 / RPM
|
||||
estimated_time = 15.0 / max(1, speed_rpm)
|
||||
time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲
|
||||
self.status = "idle"
|
||||
|
||||
return success
|
||||
|
||||
def wait_time(self, duration_s: float) -> bool:
|
||||
"""
|
||||
等待指定时间 (秒)
|
||||
"""
|
||||
self.logger.info(f"Waiting for {duration_s} seconds...")
|
||||
time.sleep(duration_s)
|
||||
return True
|
||||
|
||||
def set_zero(self) -> bool:
|
||||
"""
|
||||
清零当前位置
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
resp = self._send(0x0A, [])
|
||||
return len(resp) >= 4
|
||||
|
||||
def get_position(self) -> Optional[int]:
|
||||
"""
|
||||
读取当前位置 (脉冲数)
|
||||
|
||||
Returns:
|
||||
当前位置脉冲数,失败返回 None
|
||||
"""
|
||||
resp = self._send(0x32, [])
|
||||
|
||||
if len(resp) >= 8:
|
||||
# 响应格式: [ID] [Func] [符号位] [数值4字节] [校验]
|
||||
sign = resp[2] # 0=正, 1=负
|
||||
value = struct.unpack('>I', resp[3:7])[0]
|
||||
self.position = -value if sign == 1 else value
|
||||
|
||||
if self.debug:
|
||||
print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}")
|
||||
|
||||
return self.position
|
||||
|
||||
self.logger.warning("Failed to read position")
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""关闭串口连接并停止线程"""
|
||||
if hasattr(self, '_stop_event'):
|
||||
self._stop_event.set()
|
||||
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.close()
|
||||
self.logger.info("Serial port closed")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 测试和调试代码
|
||||
# ============================================================
|
||||
|
||||
def test_motor():
|
||||
"""基础功能测试"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("="*60)
|
||||
print("ZDT X42 电机驱动测试")
|
||||
print("="*60)
|
||||
|
||||
driver = ZDTX42Driver(
|
||||
port="/dev/tty.usbserial-3110",
|
||||
baudrate=115200,
|
||||
device_id=2,
|
||||
debug=True
|
||||
)
|
||||
|
||||
if not driver.ser:
|
||||
print("❌ 串口打开失败")
|
||||
return
|
||||
|
||||
try:
|
||||
# 测试 1: 读取位置
|
||||
print("\n[1] 读取当前位置")
|
||||
pos = driver.get_position()
|
||||
print(f"✓ 当前位置: {pos} 脉冲")
|
||||
|
||||
# 测试 2: 使能
|
||||
print("\n[2] 使能电机")
|
||||
driver.enable(True)
|
||||
time.sleep(0.3)
|
||||
print("✓ 电机已锁定")
|
||||
|
||||
# 测试 3: 相对位置运动
|
||||
print("\n[3] 相对位置运动 (1000脉冲)")
|
||||
driver.move_position(pulses=1000, speed_rpm=60, direction="CW")
|
||||
time.sleep(2)
|
||||
pos = driver.get_position()
|
||||
print(f"✓ 新位置: {pos}")
|
||||
|
||||
# 测试 4: 速度运动
|
||||
print("\n[4] 速度模式 (30RPM, 3秒)")
|
||||
driver.move_speed(speed_rpm=30, direction="CW")
|
||||
time.sleep(3)
|
||||
driver.stop()
|
||||
pos = driver.get_position()
|
||||
print(f"✓ 停止后位置: {pos}")
|
||||
|
||||
# 测试 5: 禁用
|
||||
print("\n[5] 禁用电机")
|
||||
driver.enable(False)
|
||||
print("✓ 电机已松开")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("✅ 测试完成")
|
||||
print("="*60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
driver.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_motor()
|
||||
@@ -219,10 +219,10 @@ device = NewareBatteryTestSystem(
|
||||
|
||||
#### 步骤 2:提交测试任务
|
||||
|
||||
使用 `submit_from_csv_export_ndax` 提交测试任务:
|
||||
使用 `submit_from_csv` 提交测试任务:
|
||||
|
||||
```python
|
||||
result = device.submit_from_csv_export_ndax(
|
||||
result = device.submit_from_csv(
|
||||
csv_path="test_data.csv",
|
||||
output_dir="D:/neware_output"
|
||||
)
|
||||
@@ -489,7 +489,7 @@ A: 重新获取新的 Token 并更新环境变量 `UNI_LAB_AUTH_TOKEN`。
|
||||
**Q: 可以自定义上传路径吗?**
|
||||
A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。
|
||||
|
||||
**Q: 为什么不在 `submit_from_csv_export_ndax` 中自动上传?**
|
||||
**Q: 为什么不在 `submit_from_csv` 中自动上传?**
|
||||
A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。
|
||||
|
||||
**Q: 上传后如何访问文件?**
|
||||
|
||||
@@ -230,10 +230,10 @@ device = NewareBatteryTestSystem(
|
||||
|
||||
#### Step 2: Submit Test Tasks
|
||||
|
||||
Use `submit_from_csv_export_ndax` to submit test tasks:
|
||||
Use `submit_from_csv` to submit test tasks:
|
||||
|
||||
```python
|
||||
result = device.submit_from_csv_export_ndax(
|
||||
result = device.submit_from_csv(
|
||||
csv_path="test_data.csv",
|
||||
output_dir="D:/neware_output"
|
||||
)
|
||||
@@ -500,7 +500,7 @@ A: Obtain a new API Key and update the `UNI_LAB_AUTH_TOKEN` environment variable
|
||||
**Q: Can I customize upload paths?**
|
||||
A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility).
|
||||
|
||||
**Q: Why not auto-upload in `submit_from_csv_export_ndax`?**
|
||||
**Q: Why not auto-upload in `submit_from_csv`?**
|
||||
A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility.
|
||||
|
||||
**Q: How to access files after upload?**
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"config": {
|
||||
"ip": "127.0.0.1",
|
||||
"port": 502,
|
||||
"machine_ids": [1, 2, 3, 4, 5, 6, 86],
|
||||
"machine_id": 1,
|
||||
"devtype": "27",
|
||||
"timeout": 20,
|
||||
"size_x": 500.0,
|
||||
@@ -26,10 +26,10 @@
|
||||
"data": {
|
||||
"功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能",
|
||||
"监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等",
|
||||
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务(NDA备份),或通过submit_from_csv_export_excel action提交并备份为Excel格式。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
|
||||
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
||||
import socket
|
||||
END_MARKS = [b"\r\n#\r\n", b"</bts>"] # 读到任一标志即可判定完整响应
|
||||
|
||||
def build_start_command(devid, subdevid, chlid, CoinID,
|
||||
ip_in_xml="127.0.0.1",
|
||||
devtype:int=27,
|
||||
recipe_path:str=f"D:\\HHM_test\\A001.xml",
|
||||
backup_dir:str=f"D:\\HHM_test\\backup",
|
||||
filetype:int=1) -> str:
|
||||
"""
|
||||
filetype: 备份文件类型。0=NDA(新威原生),1=Excel。默认 1。
|
||||
"""
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<bts version="1.0">',
|
||||
' <cmd>start</cmd>',
|
||||
' <list count="1">',
|
||||
f' <start ip="{ip_in_xml}" devtype="{devtype}" devid="{devid}" subdevid="{subdevid}" chlid="{chlid}" barcode="{CoinID}">{recipe_path}</start>',
|
||||
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="{int(filetype)}" backupontime="1" backupontimeinterval="1" backupfree="0" />',
|
||||
' </list>',
|
||||
'</bts>',
|
||||
]
|
||||
# TCP 模式:请求必须以 #\r\n 结束(协议要求)
|
||||
return "\r\n".join(lines) + "\r\n#\r\n"
|
||||
|
||||
def recv_until_marks(sock: socket.socket, timeout=60):
|
||||
sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2}
|
||||
buf = bytearray()
|
||||
while True:
|
||||
chunk = sock.recv(8192)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
# 读到结束标志就停,避免等对端断开
|
||||
for m in END_MARKS:
|
||||
if m in buf:
|
||||
return bytes(buf)
|
||||
# 保险:读到完整 XML 结束标签也停
|
||||
if b"</bts>" in buf:
|
||||
return bytes(buf)
|
||||
return bytes(buf)
|
||||
|
||||
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup", filetype:int=1):
|
||||
"""
|
||||
filetype: 备份文件类型,0=NDA,1=Excel。默认 1。
|
||||
"""
|
||||
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir, filetype=filetype)
|
||||
#print(xml_cmd)
|
||||
with socket.create_connection((ip, port), timeout=60) as s:
|
||||
s.sendall(xml_cmd.encode("utf-8"))
|
||||
data = recv_until_marks(s, timeout=60)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
if __name__ == "__main__":
|
||||
resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup")
|
||||
print(resp)
|
||||
@@ -623,6 +623,119 @@ class ChinweDevice(UniversalDriver):
|
||||
time.sleep(duration)
|
||||
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)
|
||||
|
||||
379
unilabos/devices/separator/xkc_sensor.py
Normal file
379
unilabos/devices/separator/xkc_sensor.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
XKC RS485 液位传感器 (Modbus RTU)
|
||||
|
||||
说明:
|
||||
1. 遵循 Modbus-RTU 协议。
|
||||
2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。
|
||||
3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。
|
||||
4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。
|
||||
"""
|
||||
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import serial
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||
|
||||
class TransportManager:
|
||||
"""
|
||||
统一通信管理类。
|
||||
仅支持 串口 (Serial/有线) 连接。
|
||||
"""
|
||||
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.logger = logger
|
||||
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
|
||||
|
||||
self.serial = None
|
||||
self._connect_serial()
|
||||
|
||||
def _connect_serial(self):
|
||||
try:
|
||||
self.serial = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=self.timeout
|
||||
)
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Serial open failed: {e}")
|
||||
|
||||
def close(self):
|
||||
"""关闭连接"""
|
||||
if self.serial and self.serial.is_open:
|
||||
self.serial.close()
|
||||
|
||||
def clear_buffer(self):
|
||||
"""清空缓冲区 (Thread-safe)"""
|
||||
with self.lock:
|
||||
if self.serial:
|
||||
self.serial.reset_input_buffer()
|
||||
|
||||
def write(self, data: bytes):
|
||||
"""发送原始字节"""
|
||||
with self.lock:
|
||||
if self.serial:
|
||||
self.serial.write(data)
|
||||
|
||||
def read(self, size: int) -> bytes:
|
||||
"""读取指定长度字节"""
|
||||
if self.serial:
|
||||
return self.serial.read(size)
|
||||
return b''
|
||||
|
||||
class XKCSensorDriver(UniversalDriver):
|
||||
"""XKC RS485 液位传感器 (Modbus RTU)"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6,
|
||||
threshold: int = 300, timeout: float = 3.0, debug: bool = False):
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.device_id = device_id
|
||||
self.threshold = threshold
|
||||
self.timeout = timeout
|
||||
self.debug = debug
|
||||
self.level = False
|
||||
self.rssi = 0
|
||||
self.status = {"level": self.level, "rssi": self.rssi}
|
||||
|
||||
try:
|
||||
self.transport = TransportManager(port, baudrate, timeout, logger=self.logger)
|
||||
self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to connect XKCSensorDriver: {e}")
|
||||
self.transport = None
|
||||
|
||||
# 启动背景轮询线程,确保 status 实时刷新
|
||||
self._stop_event = threading.Event()
|
||||
self._polling_thread = threading.Thread(
|
||||
target=self._update_loop,
|
||||
name=f"XKCPolling_{port}",
|
||||
daemon=True
|
||||
)
|
||||
if self.transport:
|
||||
self._polling_thread.start()
|
||||
|
||||
def _update_loop(self):
|
||||
"""背景循环读取传感器数据"""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self.read_level()
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
self.logger.error(f"Polling error: {e}")
|
||||
time.sleep(2.0) # 每2秒刷新一次数据
|
||||
|
||||
def _crc(self, data: bytes) -> bytes:
|
||||
crc = 0xFFFF
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
|
||||
else: crc >>= 1
|
||||
return struct.pack('<H', crc)
|
||||
|
||||
def read_level(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
读取液位。
|
||||
返回: {'level': bool, 'rssi': int}
|
||||
"""
|
||||
if not self.transport:
|
||||
return None
|
||||
|
||||
with self.transport.lock:
|
||||
self.transport.clear_buffer()
|
||||
# Modbus Read Registers: 01 03 00 01 00 02 CRC
|
||||
payload = struct.pack('>HH', 0x0001, 0x0002)
|
||||
msg = struct.pack('BB', self.device_id, 0x03) + payload
|
||||
msg += self._crc(msg)
|
||||
|
||||
if self.debug:
|
||||
self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}")
|
||||
|
||||
self.transport.write(msg)
|
||||
|
||||
# Read header
|
||||
h = self.transport.read(3) # Addr, Func, Len
|
||||
if self.debug:
|
||||
self.logger.info(f"RX Header: {h.hex().upper()}")
|
||||
|
||||
if len(h) < 3: return None
|
||||
length = h[2]
|
||||
|
||||
# Read body + CRC
|
||||
body = self.transport.read(length + 2)
|
||||
if self.debug:
|
||||
self.logger.info(f"RX Body+CRC: {body.hex().upper()}")
|
||||
if len(body) < length + 2:
|
||||
# Firmware bug fix specific to some modules
|
||||
if len(body) == 4 and length == 4:
|
||||
pass
|
||||
else:
|
||||
return None
|
||||
|
||||
data = body[:-2]
|
||||
# 根据手册说明:
|
||||
# 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液)
|
||||
# 寄存器 0x0002 (data[2:4]): 信号强度 RSSI
|
||||
|
||||
hw_level = False
|
||||
rssi = 0
|
||||
|
||||
if len(data) >= 4:
|
||||
hw_level = ((data[0] << 8) | data[1]) == 1
|
||||
rssi = (data[2] << 8) | data[3]
|
||||
elif len(data) == 2:
|
||||
# 兼容模式: 某些老固件可能只返回 1 个寄存器
|
||||
rssi = (data[0] << 8) | data[1]
|
||||
hw_level = rssi > self.threshold
|
||||
else:
|
||||
return None
|
||||
|
||||
# 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验
|
||||
# 注意: 如果用户显式设置了 THRESHOLD,我们可以在逻辑中做权衡
|
||||
self.level = hw_level or (rssi > self.threshold)
|
||||
self.rssi = rssi
|
||||
result = {
|
||||
'level': self.level,
|
||||
'rssi': self.rssi
|
||||
}
|
||||
self.status = result
|
||||
return result
|
||||
|
||||
def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool:
|
||||
"""
|
||||
等待液位达到目标状态 (阻塞式)
|
||||
"""
|
||||
self.logger.info(f"Waiting for level: {target_state}")
|
||||
start_time = time.time()
|
||||
while (time.time() - start_time) < timeout:
|
||||
res = self.read_level()
|
||||
if res and res.get('level') == target_state:
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
self.logger.warning(f"Wait level timeout ({timeout}s)")
|
||||
return False
|
||||
|
||||
def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool:
|
||||
"""
|
||||
实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。
|
||||
一旦检测到符合目标状态,立即返回。
|
||||
|
||||
Args:
|
||||
target_state: True 为“有液”, False 为“无液”
|
||||
timeout: 最大等待时间(秒)
|
||||
"""
|
||||
state_str = "有液" if target_state else "无液"
|
||||
self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)")
|
||||
|
||||
start_time = time.time()
|
||||
while (time.time() - start_time) < timeout:
|
||||
res = self.read_level() # 内部已更新 self.level 和 self.rssi
|
||||
if res:
|
||||
current_level = res.get('level')
|
||||
current_rssi = res.get('rssi')
|
||||
if current_level == target_state:
|
||||
self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})")
|
||||
return True
|
||||
|
||||
if self.debug:
|
||||
self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}")
|
||||
|
||||
time.sleep(0.2) # 高频采样
|
||||
|
||||
self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)")
|
||||
return False
|
||||
|
||||
def set_threshold(self, threshold: int):
|
||||
"""设置液位判定阈值"""
|
||||
self.threshold = int(threshold)
|
||||
self.logger.info(f"Threshold updated to: {self.threshold}")
|
||||
|
||||
def change_device_id(self, new_id: int) -> bool:
|
||||
"""
|
||||
修改设备的 Modbus 从站地址。
|
||||
寄存器: 0x0004, 功能码: 0x06
|
||||
"""
|
||||
if not (1 <= new_id <= 254):
|
||||
self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.")
|
||||
return False
|
||||
|
||||
self.logger.info(f"Changing device ID from {self.device_id} to {new_id}")
|
||||
success = self._write_single_register(0x0004, new_id)
|
||||
if success:
|
||||
self.device_id = new_id # 更新内存中的地址
|
||||
self.logger.info(f"Device ID update command sent successfully (target {new_id}).")
|
||||
return success
|
||||
|
||||
def change_baudrate(self, baud_code: int) -> bool:
|
||||
"""
|
||||
更改通讯波特率 (寄存器: 0x0005)。
|
||||
设置成功后传感器 LED 会闪烁,通常无数据返回。
|
||||
|
||||
波特率代码对照表 (16进制):
|
||||
05: 2400
|
||||
06: 4800
|
||||
07: 9600 (默认)
|
||||
08: 14400
|
||||
09: 19200
|
||||
0A: 28800
|
||||
0C: 57600
|
||||
0D: 115200
|
||||
0E: 128000
|
||||
0F: 256000
|
||||
"""
|
||||
self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})")
|
||||
# 写入寄存器 0x0005
|
||||
self._write_single_register(0x0005, baud_code)
|
||||
self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.")
|
||||
return True
|
||||
|
||||
def factory_reset(self) -> bool:
|
||||
"""
|
||||
恢复出厂设置 (通过广播地址 FF)。
|
||||
设置地址为 01,逻辑为向 0x0004 写入 0x0002
|
||||
"""
|
||||
self.logger.info("Sending factory reset command via broadcast address FF...")
|
||||
# 广播指令通常无回显
|
||||
self._write_single_register(0x0004, 0x0002, slave_id=0xFF)
|
||||
self.logger.info("Factory reset command sent. Device address should be 01 now.")
|
||||
return True
|
||||
|
||||
def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool:
|
||||
"""内部辅助函数: Modbus 功能码 06 写单个寄存器"""
|
||||
if not self.transport: return False
|
||||
|
||||
target_id = slave_id if slave_id is not None else self.device_id
|
||||
msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value)
|
||||
msg += self._crc(msg)
|
||||
|
||||
with self.transport.lock:
|
||||
self.transport.clear_buffer()
|
||||
if self.debug:
|
||||
self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}")
|
||||
|
||||
self.transport.write(msg)
|
||||
|
||||
# 广播地址、波特率修改或厂家特定指令可能无回显
|
||||
if target_id == 0xFF or reg_addr == 0x0005:
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
|
||||
# 等待返回 (正常应返回相同报文)
|
||||
resp = self.transport.read(len(msg))
|
||||
if self.debug:
|
||||
self.logger.info(f"RX Write Response: {resp.hex().upper()}")
|
||||
|
||||
return resp == msg
|
||||
|
||||
def close(self):
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 快速实例化测试
|
||||
import logging
|
||||
# 减少冗余日志,仅显示重要信息
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
|
||||
# 硬件配置 (根据实际情况修改)
|
||||
TEST_PORT = "/dev/tty.usbserial-3110"
|
||||
SLAVE_ID = 1
|
||||
THRESHOLD = 300
|
||||
|
||||
print("\n" + "="*50)
|
||||
print(f" XKC RS485 传感器独立测试程序")
|
||||
print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}")
|
||||
print("="*50)
|
||||
|
||||
sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False)
|
||||
|
||||
try:
|
||||
if sensor.transport:
|
||||
print(f"\n开始实时连续采样测试 (持续 15 秒)...")
|
||||
print(f"按 Ctrl+C 可提前停止\n")
|
||||
|
||||
start_time = time.time()
|
||||
duration = 15
|
||||
count = 0
|
||||
|
||||
while time.time() - start_time < duration:
|
||||
count += 1
|
||||
res = sensor.read_level()
|
||||
if res:
|
||||
rssi = res['rssi']
|
||||
level = res['level']
|
||||
status_str = "【有液】" if level else "【无液】"
|
||||
# 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史)
|
||||
# 为了方便查看变化,我们直接打印
|
||||
elapsed = time.time() - start_time
|
||||
print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}")
|
||||
else:
|
||||
print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)")
|
||||
|
||||
time.sleep(0.5) # 每秒采样 2 次
|
||||
|
||||
print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---")
|
||||
|
||||
# [3] 测试动态修改阈值
|
||||
print(f"\n[3] 动态修改阈值演示...")
|
||||
new_threshold = 400
|
||||
sensor.set_threshold(new_threshold)
|
||||
res = sensor.read_level()
|
||||
if res:
|
||||
print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}")
|
||||
sensor.set_threshold(THRESHOLD) # 还原
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[!] 用户中断测试")
|
||||
except Exception as e:
|
||||
print(f"\n[!] 测试运行出错: {e}")
|
||||
finally:
|
||||
sensor.close()
|
||||
print("\n--- 测试程序已退出 ---\n")
|
||||
@@ -2,6 +2,8 @@ import time
|
||||
import logging
|
||||
from typing import Union, Dict, Optional
|
||||
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
|
||||
class VirtualMultiwayValve:
|
||||
"""
|
||||
@@ -41,13 +43,11 @@ class VirtualMultiwayValve:
|
||||
def target_position(self) -> int:
|
||||
return self._target_position
|
||||
|
||||
def get_current_position(self) -> int:
|
||||
"""获取当前阀门位置 📍"""
|
||||
return self._current_position
|
||||
|
||||
def get_current_port(self) -> str:
|
||||
"""获取当前连接的端口名称 🔌"""
|
||||
return self._current_position
|
||||
@property
|
||||
@topic_config()
|
||||
def current_port(self) -> str:
|
||||
"""当前连接的端口名称 🔌"""
|
||||
return self.port
|
||||
|
||||
def set_position(self, command: Union[int, str]):
|
||||
"""
|
||||
@@ -169,12 +169,14 @@ class VirtualMultiwayValve:
|
||||
self._status = "Idle"
|
||||
self._valve_state = "Closed"
|
||||
|
||||
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})"
|
||||
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
|
||||
self.logger.info(close_msg)
|
||||
return close_msg
|
||||
|
||||
def get_valve_position(self) -> int:
|
||||
"""获取阀门位置 - 兼容性方法 📍"""
|
||||
@property
|
||||
@topic_config()
|
||||
def valve_position(self) -> int:
|
||||
"""阀门位置 📍"""
|
||||
return self._current_position
|
||||
|
||||
def set_valve_position(self, command: Union[int, str]):
|
||||
@@ -229,19 +231,16 @@ class VirtualMultiwayValve:
|
||||
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
||||
return self.set_to_pump_position()
|
||||
|
||||
def get_flow_path(self) -> str:
|
||||
"""获取当前流路路径描述 🌊"""
|
||||
current_port = self.get_current_port()
|
||||
@property
|
||||
@topic_config()
|
||||
def flow_path(self) -> str:
|
||||
"""当前流路路径描述 🌊"""
|
||||
if self._current_position == 0:
|
||||
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})"
|
||||
else:
|
||||
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
|
||||
|
||||
# 删除debug日志:self.logger.debug(f"🌊 当前流路: {flow_path}")
|
||||
return flow_path
|
||||
return f"🚰 转移泵已连接 (位置 {self._current_position})"
|
||||
return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
|
||||
|
||||
def __str__(self):
|
||||
current_port = self.get_current_port()
|
||||
current_port = self.current_port
|
||||
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
||||
|
||||
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
||||
@@ -253,7 +252,7 @@ if __name__ == "__main__":
|
||||
|
||||
print("🔄 === 虚拟九通阀门测试 === ✨")
|
||||
print(f"🏠 初始状态: {valve}")
|
||||
print(f"🌊 当前流路: {valve.get_flow_path()}")
|
||||
print(f"🌊 当前流路: {valve.flow_path}")
|
||||
|
||||
# 切换到试剂瓶1(1号位)
|
||||
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import time as time_module
|
||||
from typing import Dict, Any
|
||||
|
||||
from unilabos.registry.decorators import topic_config
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
class VirtualStirrer:
|
||||
@@ -314,9 +315,11 @@ class VirtualStirrer:
|
||||
def min_speed(self) -> float:
|
||||
return self._min_speed
|
||||
|
||||
def get_device_info(self) -> Dict[str, Any]:
|
||||
"""获取设备状态信息 📊"""
|
||||
info = {
|
||||
@property
|
||||
@topic_config()
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""设备状态快照信息 📊"""
|
||||
return {
|
||||
"device_id": self.device_id,
|
||||
"status": self.status,
|
||||
"operation_mode": self.operation_mode,
|
||||
@@ -325,12 +328,9 @@ class VirtualStirrer:
|
||||
"is_stirring": self.is_stirring,
|
||||
"remaining_time": self.remaining_time,
|
||||
"max_speed": self._max_speed,
|
||||
"min_speed": self._min_speed
|
||||
"min_speed": self._min_speed,
|
||||
}
|
||||
|
||||
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
|
||||
return info
|
||||
|
||||
|
||||
def __str__(self):
|
||||
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
||||
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
||||
@@ -4,6 +4,7 @@ from enum import Enum
|
||||
from typing import Union, Optional
|
||||
import logging
|
||||
|
||||
from unilabos.registry.decorators import topic_config
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
@@ -385,8 +386,10 @@ class VirtualTransferPump:
|
||||
"""获取当前体积"""
|
||||
return self._current_volume
|
||||
|
||||
def get_remaining_capacity(self) -> float:
|
||||
"""获取剩余容量"""
|
||||
@property
|
||||
@topic_config()
|
||||
def remaining_capacity(self) -> float:
|
||||
"""剩余容量 (ml)"""
|
||||
return self.max_volume - self._current_volume
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -760,9 +760,10 @@ class BioyondWorkstation(WorkstationBase):
|
||||
except:
|
||||
pass
|
||||
|
||||
# 创建通信模块;同步器将在 post_init 中初始化并执行首次同步
|
||||
# 创建通信模块
|
||||
self._create_communication_module(bioyond_config)
|
||||
self.resource_synchronizer = None
|
||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
# TODO: self._ros_node里面拿属性
|
||||
|
||||
@@ -801,15 +802,6 @@ class BioyondWorkstation(WorkstationBase):
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化仓库
|
||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
|
||||
self.deck.setup()
|
||||
|
||||
# 初始化同步器并执行首次同步(需在仓库初始化之后)
|
||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
# 启动连接监控
|
||||
try:
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# 代码变更说明 — 2026-03-12
|
||||
|
||||
> 本次变更基于 `implementation_plan_v2.md` 执行,目标:**物理几何结构初始化与物料内容物填充彻底解耦**,消除 PLR 反序列化时的 `Resource already assigned to deck` 错误,并修复若干运行时新增问题。
|
||||
|
||||
---
|
||||
|
||||
## 一、物料系统标准化重构(主线任务)
|
||||
|
||||
### 1. `unilabos/resources/battery/magazine.py`
|
||||
|
||||
**改动**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 三个工厂函数的 `klasses` 参数改为 `None`。
|
||||
|
||||
**原因**:原来三个工厂函数在初始化时就向洞位填满极片对象(`ElectrodeSheet`),导致 PLR 反序列化时"几何结构已创建子节点 + DB 再次 assign"双重冲突。
|
||||
|
||||
**原则**:物料余量改由寄存器直读(阶段 F),资源树不再追踪每个极片实体。`MagazineHolder_6_Battery` 原本就是 `klasses=None`,三者现在保持一致。
|
||||
|
||||
---
|
||||
|
||||
### 2. `unilabos/resources/battery/magazine.py`(追加,响应重复 UUID 问题)
|
||||
|
||||
**改动**:为 `Magazine`(洞位类)新增 `serialize` 和 `deserialize` 重写:
|
||||
- `serialize`:序列化时强制将 `children` 置空,不再把极片写回数据库。
|
||||
- `deserialize`:反序列化时强制忽略 `children` 字段,阻止数据库中旧极片记录被恢复。
|
||||
|
||||
**原因**:数据库中遗留有旧的 `ElectrodeSheet` 记录(`A1_sheet100` 等),启动时被 PLR 反序列化进来,导致同一 UUID 出现在多个 Magazine 洞位中,触发 `发现重复的uuid` 错误。此修复从源头截断旧数据,经过一次完整的"启动 → 资源树写回"后,数据库旧极片记录也会被干净覆盖。
|
||||
|
||||
---
|
||||
|
||||
### 3. `unilabos/resources/battery/bottle_carriers.py`
|
||||
|
||||
**改动**:删除 `YIHUA_Electrolyte_12VialCarrier` 末尾的 12 瓶填充循环及对应 `import`。
|
||||
|
||||
**原因**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 应初始化为空载架,瓶子由 Bioyond 侧实际转运后再填入。原来初始化时直接塞满 `YB_pei_ye_xiao_Bottle`,反序列化时产生重复 assign。
|
||||
|
||||
---
|
||||
|
||||
### 4. `unilabos/resources/bioyond/decks.py`
|
||||
|
||||
**改动**:
|
||||
- 将 `BIOYOND_YB_Deck` 重命名为 `BioyondElectrolyteDeck`,保留 `BIOYOND_YB_Deck` 作为向后兼容别名。
|
||||
- 工厂函数 `YB_Deck()` 重命名为 `bioyond_electrolyte_deck()`,保留 `YB_Deck` 作为别名。
|
||||
- `BIOYOND_PolymerReactionStation_Deck`、`BIOYOND_PolymerPreparationStation_Deck`、`BioyondElectrolyteDeck` 三个 Deck 类:
|
||||
- 移除 `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用。
|
||||
- 删除临时 `deserialize` 补丁(该补丁是为了强制 `setup=False`,根本原因消除后不再需要)。
|
||||
|
||||
**原因**:`setup` 参数导致 PLR 反序列化时先通过 `__init__` 创建所有子资源,再从 JSON `children` 字段再次 assign,产生 `already assigned to deck` 错误。正确模式:`__init__` 只初始化自身几何,`setup()` 由工厂函数调用,反序列化由 PLR 从 DB 数据重建子资源。
|
||||
|
||||
---
|
||||
|
||||
### 5. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
|
||||
|
||||
**改动**:
|
||||
- `CoincellDeck` 重命名为 `YihuaCoinCellDeck`,保留 `CoincellDeck` 作为向后兼容别名。
|
||||
- 工厂函数 `YH_Deck()` 重命名为 `yihua_coin_cell_deck()`,保留 `YH_Deck` 作为别名。
|
||||
- 移除 `YihuaCoinCellDeck.__init__` 中的 `setup: bool = False` 参数及调用,删除 `deserialize` 补丁(原因同 decks.py)。
|
||||
- `MaterialPlate.__init__` 移除 `fill` 参数和 `fill=True` 分支,新增类方法 `MaterialPlate.create_with_holes()` 作为"带洞位"的工厂方法,`setup()` 改为调用该工厂方法。
|
||||
- `YihuaCoinCellDeck.setup()` 末尾新增 `electrolyte_buffer`(`ResourceStack`)接驳槽,用于接收来自 Bioyond 侧的分液瓶板,命名与 `bioyond_cell_workstation.py` 中 `sites=["electrolyte_buffer"]` 一致。
|
||||
|
||||
---
|
||||
|
||||
### 6. `unilabos/resources/resource_tracker.py`
|
||||
|
||||
**改动 1**:`to_plr_resources` 中,`load_all_state` 调用前预填 `Container` 类资源缺失的键:
|
||||
|
||||
```python
|
||||
state.setdefault("liquid_history", [])
|
||||
state.setdefault("pending_liquids", {})
|
||||
```
|
||||
|
||||
**原因**:新版 PLR 要求 `Container` 状态中必须包含这两个键,旧数据库记录缺失时 `load_all_state` 会抛出 `KeyError`。
|
||||
|
||||
**改动 2**:`_validate_tree` 中,遇到重复 UUID 时改为自动重新分配新 UUID 并打 `WARNING`,不再直接抛异常崩溃。
|
||||
|
||||
**原因**:旧数据库中存在多个同名同 UUID 的极片对象(历史脏数据),严格校验会导致节点无法启动。改为 WARNING + 自动修复,确保启动成功,下次资源树写回后脏数据自然清除。
|
||||
|
||||
---
|
||||
|
||||
### 7. `unilabos/resources/itemized_carrier.py`
|
||||
|
||||
**改动**:将原来的 `idx is None` 兜底补丁(静默调用 `super().assign_child_resource`,不更新槽位追踪)替换为两段式逻辑:
|
||||
|
||||
1. **XY 近似匹配**(容差 2mm):精确三维坐标匹配失败时,仅对比 XY 二维坐标,找到最近槽位后用槽位的正确坐标(含 Z)完成 assign,并打 `WARNING`。
|
||||
2. **XY 也失败才抛异常**:给出详细的槽位列表和传入坐标,便于问题排查。
|
||||
|
||||
**原因**:数据库中存储的资源坐标 Z=0,而 `warehouse_factory` 定义的槽位 Z=dz(如 10mm)。精确匹配永远失败,原补丁静默兜底掩盖了这一问题。近似匹配修复了 Z 偏移,同时保留了真正异常时的报错能力。
|
||||
|
||||
---
|
||||
|
||||
### 8. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
**改动 1**:更新导入:`BIOYOND_YB_Deck` → `BioyondElectrolyteDeck, bioyond_electrolyte_deck`。
|
||||
|
||||
**改动 2**:`__main__` 入口处改为调用 `bioyond_electrolyte_deck(name="YB_Deck")`。
|
||||
|
||||
**改动 3**:新增 `_get_resource_from_device(device_id, resource_name)` 方法,用于从目标设备的资源树中动态查找 PLR 资源对象(带降级回退逻辑)。
|
||||
|
||||
**改动 4**:跨站转运逻辑中,将原来"创建 `size=1,1,1` 的虚拟 `ResourcePLR` + 硬编码 UUID"的方式,改为通过 `_get_resource_from_device` 从目标设备获取真实的 `electrolyte_buffer` 资源对象。
|
||||
|
||||
**原因**:原代码使用硬编码 UUID 的虚拟资源作为转运目标,该对象在 YihuaCoinCellDeck 的资源树中不存在,转移后资源树状态混乱。
|
||||
|
||||
---
|
||||
|
||||
### 9. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||
|
||||
**改动 1**:更新导入:`CoincellDeck` → `YihuaCoinCellDeck, yihua_coin_cell_deck`,`__main__` 入口改为调用 `yihua_coin_cell_deck()`。
|
||||
|
||||
**改动 2**:新增 10 个 `@property`,实现对依华扣电工站 Modbus 寄存器的直读:
|
||||
|
||||
| 属性名 | 寄存器地址 | 说明 |
|
||||
|---|---|---|
|
||||
| `data_10mm_positive_plate_remaining` | 520 | 10mm正极片余量 |
|
||||
| `data_12mm_positive_plate_remaining` | 522 | 12mm正极片余量 |
|
||||
| `data_16mm_positive_plate_remaining` | 524 | 16mm正极片余量 |
|
||||
| `data_aluminum_foil_remaining` | 526 | 铝箔余量 |
|
||||
| `data_positive_shell_remaining` | 528 | 正极壳余量 |
|
||||
| `data_flat_washer_remaining` | 530 | 平垫余量 |
|
||||
| `data_negative_shell_remaining` | 532 | 负极壳余量 |
|
||||
| `data_spring_washer_remaining` | 534 | 弹垫余量 |
|
||||
| `data_finished_battery_remaining_capacity` | 536 | 成品电池余量 |
|
||||
| `data_finished_battery_ng_remaining_capacity` | 538 | 成品电池NG槽余量 |
|
||||
|
||||
**原因**:`coin_cell_workstation.yaml` 的 `status_types` 中定义了这 10 个属性,但代码中从未实现,导致每次前端轮询时均报 `AttributeError`。
|
||||
|
||||
---
|
||||
|
||||
## 二、配置与注册表更新
|
||||
|
||||
### 10. `yibin_electrolyte_config.json`
|
||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`(class、type、_resource_type 三处)
|
||||
- `CoincellDeck` → `YihuaCoinCellDeck`(class、type、_resource_type 三处)
|
||||
- 移除 `"setup": true` 字段
|
||||
|
||||
### 11. `yibin_coin_cell_only_config.json`
|
||||
- `CoincellDeck` → `YihuaCoinCellDeck`
|
||||
- 移除 `"setup": true`
|
||||
|
||||
### 12. `yibin_electrolyte_only_config.json`
|
||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`
|
||||
- 移除 `"setup": true`
|
||||
|
||||
### 13. `unilabos/registry/resources/bioyond/deck.yaml`
|
||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`,工厂函数路径更新为 `bioyond_electrolyte_deck`
|
||||
- `CoincellDeck` → `YihuaCoinCellDeck`,工厂函数路径更新为 `yihua_coin_cell_deck`
|
||||
|
||||
---
|
||||
|
||||
## 三、独立 Bug 修复
|
||||
|
||||
### 14. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv`
|
||||
|
||||
**改动**:10 条余量寄存器记录的 `DataType` 列从 `REAL` 改为 `FLOAT32`。
|
||||
|
||||
**原因**:`REAL` 是 IEC 61131-3 PLC 工程师惯用名称,但 pymodbus 的 `DATATYPE` 枚举只有 `FLOAT32`,`DataType['REAL']` 查表时抛 `KeyError: 'REAL'`,导致 `CoinCellAssemblyWorkstation` 节点启动失败。
|
||||
|
||||
---
|
||||
|
||||
## 四、运行期新增 Bug 修复(第二轮,2026-03-12 18:12 日志)
|
||||
|
||||
### 15. `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||
|
||||
**改动**:第 261 行 `self.bioyond_config` → `self.workstation.bioyond_config`。
|
||||
|
||||
**原因**:`BioyondResourceSynchronizer.sync_to_external` 内部误用了 `self.bioyond_config`,而该类从未设置此属性(应通过 `self.workstation.bioyond_config` 访问)。触发场景:用户在前端将任意物料拖入仓库时,同步到 Bioyond 必定抛出 `AttributeError: 'BioyondResourceSynchronizer' object has no attribute 'bioyond_config'`。
|
||||
|
||||
---
|
||||
|
||||
### 16. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
**改动**:`_get_type_id_by_name` 方法新增"直接英文 key 命中"分支:
|
||||
|
||||
- **原逻辑**:仅按 `value[0]`(中文名,如 `"5ml分液瓶板"`)遍历比较。
|
||||
- **新逻辑**:先以 `type_name` 直接查找 `material_type_mappings` 字典 key(英文 model 名,如 `"YB_Vial_5mL_Carrier"`),命中则立即返回 UUID;否则再按中文名兜底遍历。
|
||||
|
||||
**原因**:`resource_tree_transfer` 将 `plr_resource.model`(英文 key)作为 `board_type` / `bottle_type` 传给 `create_sample`,后者再调用 `_get_type_id_by_name`。旧版函数只按中文名查,导致英文 key 永远匹配不到 → `ValueError: 未找到板类型 'YB_Vial_5mL_Carrier' 的配置`。新函数兼容两种查找方式,同时保持向后兼容。
|
||||
|
||||
---
|
||||
|
||||
## 五、运行期新增 Bug 修复(第三轮,2026-03-12 20:30 日志)
|
||||
|
||||
### 17. `unilabos/resources/resource_tracker.py`(追加)
|
||||
|
||||
**改动**:在 `to_plr_resources` 中,`sub_cls.deserialize` 调用前新增 `_deduplicate_plr_dict(plr_dict)` 预处理函数。
|
||||
|
||||
**函数逻辑**:递归遍历整个 `plr_dict` 树,在**全树范围**对 `children` 列表按 `name` 去重——保留首次出现的同名节点,跳过重复项并打 `WARNING`。
|
||||
|
||||
**根本原因**:
|
||||
1. 用户通过前端将 `YB_Vial_5mL_Carrier` 拖入仓库 E01,carrier 及其子 vial(`YB_Vial_5mL_Carrier_vial_A1` 等)被写入数据库。
|
||||
2. 随后 `sync_from_external`(Bioyond 定期同步)以**新 UUID** 重新创建同名 carrier 并赋给同一槽位,PLR 内存树中的旧 carrier 被替换,但**数据库旧记录未被清除**。
|
||||
3. 下次重启时,数据库同一 `WareHouse` 下存在两条同名 `BottleCarrier`(不同 UUID),`node_to_plr_dict` 将二者都放入 `children` 列表,PLR 反序列化第二个 carrier 时子 vial 命名冲突,抛出 `ValueError: Resource with name 'YB_Vial_5mL_Carrier_vial_A1' already exists in the tree.`,整个 deck 无法加载,系统启动失败。
|
||||
|
||||
**连锁错误(随根因修复自动消除)**:
|
||||
- `TypeError: Deck.__init__() got an unexpected keyword argument 'data'` — deck 加载失败后 `driver_creator.py` 触发降级路径,参数类型错误
|
||||
- `AttributeError: 'ResourceDictInstance' object has no attribute 'copy'` — 另一条降级路径失败
|
||||
- `ValueError: Deck 配置不能为空` — 所有 deck 创建路径失败,`deck=None` 传入工作站
|
||||
|
||||
---
|
||||
|
||||
> **验证状态**:2026-03-12 20:56 日志确认系统正常运行,无新增 ERROR 级错误。
|
||||
|
||||
---
|
||||
|
||||
## 六、变更文件汇总(最终)
|
||||
|
||||
| 文件 | 变更类型 | 轮次 |
|
||||
|---|---|---|
|
||||
| `resources/battery/magazine.py` | 重构 + Bug 修复(极片子节点解耦 + 旧数据清理) | 第一轮 |
|
||||
| `resources/battery/bottle_carriers.py` | 重构(移除初始化时自动填瓶) | 第一轮 |
|
||||
| `resources/bioyond/decks.py` | 重构 + 重命名(BioyondElectrolyteDeck) | 第一轮 |
|
||||
| `devices/workstation/coin_cell_assembly/YB_YH_materials.py` | 重构 + 重命名(YihuaCoinCellDeck)+ 新增 electrolyte_buffer 槽位 | 第一轮 |
|
||||
| `resources/resource_tracker.py` | Bug 修复 × 3(Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重) | 第一/三轮 |
|
||||
| `resources/itemized_carrier.py` | Bug 修复(XY 近似坐标匹配,修复 Z 偏移) | 第一轮 |
|
||||
| `devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | 重构 + Bug 修复(跨站转运 + 类型映射双模式查找) | 第一/二轮 |
|
||||
| `devices/workstation/bioyond_studio/station.py` | Bug 修复(sync_to_external 属性访问路径) | 第二轮 |
|
||||
| `devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | 新增 10 个 Modbus 余量属性 + 更新导入 | 第一轮 |
|
||||
| `yibin_electrolyte_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||
| `yibin_coin_cell_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||
| `yibin_electrolyte_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||
| `registry/resources/bioyond/deck.yaml` | 注册表更新(类名 + 工厂函数路径) | 第一轮 |
|
||||
| `devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` | Bug 修复(REAL → FLOAT32) | 第一轮 |
|
||||
@@ -130,14 +130,20 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
ordering: Optional[OrderedDict[str, str]] = None,
|
||||
category: str = "material_plate",
|
||||
model: Optional[str] = None,
|
||||
fill: bool = False
|
||||
):
|
||||
"""初始化料板(不主动填充洞位,由工厂方法或反序列化恢复)
|
||||
"""初始化料板
|
||||
|
||||
Args:
|
||||
name: 料板名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
hole_spacing_x: X方向洞位间距 (mm)
|
||||
hole_spacing_y: Y方向洞位间距 (mm)
|
||||
number: 编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
@@ -147,50 +153,42 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
hole_diameter=20.0,
|
||||
info="",
|
||||
)
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_with_holes(
|
||||
cls,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "material_plate",
|
||||
model: Optional[str] = None,
|
||||
) -> "MaterialPlate":
|
||||
"""工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup,不在反序列化路径调用)"""
|
||||
# 默认洞位间距(与 _unilabos_state 默认值保持一致)
|
||||
hole_spacing_x = 24.0
|
||||
hole_spacing_y = 24.0
|
||||
# 先建洞位,再作为 ordered_items 传入构造函数
|
||||
# (ItemizedResource.__init__ 要求 ordered_items 或 ordering 二选一必须有值)
|
||||
# 创建4x4的洞位
|
||||
# TODO: 这里要改,对应不同形状
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(size_x - 4 * hole_spacing_x) / 2,
|
||||
dy=(size_y - 4 * hole_spacing_y) / 2,
|
||||
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=size_z,
|
||||
item_dx=hole_spacing_x,
|
||||
item_dy=hole_spacing_y,
|
||||
size_x=16,
|
||||
size_y=16,
|
||||
size_z=16,
|
||||
)
|
||||
return cls(
|
||||
name=name, size_x=size_x, size_y=size_y, size_z=size_z,
|
||||
ordered_items=holes, category=category, model=model,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 16,
|
||||
size_y = 16,
|
||||
size_z = 16,
|
||||
)
|
||||
if fill:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=holes,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
else:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def update_locations(self):
|
||||
# TODO:调多次相加
|
||||
@@ -536,19 +534,30 @@ class WasteTipBox(Trash):
|
||||
return data
|
||||
|
||||
|
||||
class YihuaCoinCellDeck(Deck):
|
||||
"""依华纽扣电池组装工作站台面类"""
|
||||
class CoincellDeck(Deck):
|
||||
"""纽扣电池组装工作站台面类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "coin_cell_deck",
|
||||
size_x: float = 1450.0,
|
||||
size_y: float = 1450.0,
|
||||
size_z: float = 100.0,
|
||||
size_x: float = 1450.0, # 1m
|
||||
size_y: float = 1450.0, # 1m
|
||||
size_z: float = 100.0, # 0.9m
|
||||
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||
category: str = "coin_cell_deck",
|
||||
setup: bool = False,
|
||||
setup: bool = False, # 是否自动执行 setup
|
||||
):
|
||||
"""初始化纽扣电池组装工作站台面
|
||||
|
||||
Args:
|
||||
name: 台面名称
|
||||
size_x: 长度 (mm) - 1m
|
||||
size_y: 宽度 (mm) - 1m
|
||||
size_z: 高度 (mm) - 0.9m
|
||||
origin: 原点坐标
|
||||
category: 类别
|
||||
setup: 是否自动执行 setup 配置标准布局
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1450.0,
|
||||
@@ -582,11 +591,14 @@ class YihuaCoinCellDeck(Deck):
|
||||
# ====================================== 物料板 ============================================
|
||||
# 创建物料板(料盘carrier)- 4x4布局
|
||||
# 负极料盘
|
||||
fujiliaopan = MaterialPlate.create_with_holes(name="负极料盘", size_x=120, size_y=100, size_z=10.0)
|
||||
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||
# for i in range(16):
|
||||
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||
|
||||
# 隔膜料盘
|
||||
gemoliaopan = MaterialPlate.create_with_holes(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0)
|
||||
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||
# for i in range(16):
|
||||
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
@@ -621,27 +633,11 @@ class YihuaCoinCellDeck(Deck):
|
||||
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||
|
||||
# 分液瓶板接驳区 - 接收来自 BioyondElectrolyte 侧的完整 Vial Carrier 板
|
||||
# 命名 electrolyte_buffer 与 bioyond_cell_workstation.py 中 sites=["electrolyte_buffer"] 对应
|
||||
electrolyte_buffer = ResourceStack(
|
||||
name="electrolyte_buffer",
|
||||
direction="z",
|
||||
resources=[],
|
||||
)
|
||||
self.assign_child_resource(electrolyte_buffer, Coordinate(x=1050.0, y=700.0, z=0))
|
||||
|
||||
|
||||
def yihua_coin_cell_deck(name: str = "coin_cell_deck") -> YihuaCoinCellDeck:
|
||||
deck = YihuaCoinCellDeck(name=name)
|
||||
deck.setup()
|
||||
return deck
|
||||
|
||||
|
||||
# 向后兼容别名,日后废弃
|
||||
CoincellDeck = YihuaCoinCellDeck
|
||||
|
||||
def YH_Deck(name: str = "") -> YihuaCoinCellDeck:
|
||||
return yihua_coin_cell_deck(name=name or "coin_cell_deck")
|
||||
def YH_Deck(name=""):
|
||||
cd = CoincellDeck(name=name)
|
||||
cd.setup()
|
||||
return cd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,7 +6,7 @@ import threading
|
||||
import time
|
||||
import types
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
from functools import wraps
|
||||
from pylabrobot.resources import Deck, Resource as PLRResource
|
||||
from unilabos_msgs.msg import Resource
|
||||
@@ -17,7 +17,7 @@ from unilabos.device_comms.modbus_plc.modbus import DeviceType, Base as ModbusNo
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import *
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck, yihua_coin_cell_deck
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
|
||||
from unilabos.resources.graphio import convert_resources_to_type
|
||||
from unilabos.utils.log import logger
|
||||
import struct
|
||||
@@ -161,9 +161,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
logger.info("没有传入依华deck,检查启动json文件")
|
||||
super().__init__(deck=deck, *args, **kwargs,)
|
||||
self.debug_mode = debug_mode
|
||||
self._modbus_address = address
|
||||
self._modbus_port = port
|
||||
|
||||
|
||||
""" 连接初始化 """
|
||||
modbus_client = TCPClient(addr=address, port=port)
|
||||
logger.debug(f"创建 Modbus 客户端: {modbus_client}")
|
||||
@@ -180,11 +178,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
raise ValueError('modbus tcp connection failed')
|
||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_b.csv'))
|
||||
self.client = modbus_client.register_node_list(self.nodes)
|
||||
self._modbus_client_raw = modbus_client
|
||||
else:
|
||||
print("测试模式,跳过连接")
|
||||
self.nodes, self.client = None, None
|
||||
self._modbus_client_raw = None
|
||||
|
||||
""" 工站的配置 """
|
||||
|
||||
@@ -195,40 +191,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
self.csv_export_file = None
|
||||
self.coin_num_N = 0 #已组装电池数量
|
||||
|
||||
def _ensure_modbus_connected(self) -> None:
|
||||
"""检查 Modbus TCP 连接是否存活,若已断开则自动重连(防止长时间空闲后连接超时)"""
|
||||
if self.debug_mode or self._modbus_client_raw is None:
|
||||
return
|
||||
raw_client = self._modbus_client_raw.client
|
||||
if raw_client.is_socket_open():
|
||||
return
|
||||
logger.warning("[Modbus] 检测到连接已断开,尝试重连...")
|
||||
try:
|
||||
raw_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
count = 10
|
||||
while count > 0:
|
||||
count -= 1
|
||||
try:
|
||||
raw_client.connect()
|
||||
except Exception:
|
||||
pass
|
||||
if raw_client.is_socket_open():
|
||||
break
|
||||
time.sleep(2)
|
||||
if not raw_client.is_socket_open():
|
||||
raise RuntimeError(f"Modbus TCP 重连失败({self._modbus_address}:{self._modbus_port}),请检查设备连接")
|
||||
logger.info("[Modbus] 重连成功")
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化子物料
|
||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
|
||||
self.deck.setup()
|
||||
|
||||
#self.deck = create_a_coin_cell_deck()
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
})
|
||||
@@ -658,28 +623,12 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
return vol
|
||||
|
||||
@property
|
||||
def data_coin_type(self) -> int:
|
||||
"""电池类型 - 7种或8种组装物料 (INT16)"""
|
||||
if self.debug_mode:
|
||||
return 7
|
||||
coin_type, read_err = self.client.use_node('REG_DATA_COIN_TYPE').read(1)
|
||||
return coin_type
|
||||
|
||||
@property
|
||||
def data_current_assembling_count(self) -> int:
|
||||
"""当前进行组装的电池数量 - Current assembling battery count (INT16)"""
|
||||
def data_coin_num(self) -> int:
|
||||
"""当前电池数量 (INT16)"""
|
||||
if self.debug_mode:
|
||||
return 0
|
||||
count, read_err = self.client.use_node('REG_DATA_CURRENT_ASSEMBLING_COUNT').read(1)
|
||||
return count
|
||||
|
||||
@property
|
||||
def data_current_completed_count(self) -> int:
|
||||
"""当前完成组装的电池数量 - Current completed battery count (INT16)"""
|
||||
if self.debug_mode:
|
||||
return 0
|
||||
count, read_err = self.client.use_node('REG_DATA_CURRENT_COMPLETED_COUNT').read(1)
|
||||
return count
|
||||
num, read_err = self.client.use_node('REG_DATA_COIN_NUM').read(1)
|
||||
return num
|
||||
|
||||
@property
|
||||
def data_coin_cell_code(self) -> str:
|
||||
@@ -777,116 +726,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_10mm_positive_plate_remaining(self) -> float:
|
||||
"""10mm正极片剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取10mm正极片余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_12mm_positive_plate_remaining(self) -> float:
|
||||
"""12mm正极片剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取12mm正极片余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_16mm_positive_plate_remaining(self) -> float:
|
||||
"""16mm正极片剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取16mm正极片余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_aluminum_foil_remaining(self) -> float:
|
||||
"""铝箔剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取铝箔余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_positive_shell_remaining(self) -> float:
|
||||
"""正极壳剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_POSITIVE_SHELL_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取正极壳余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_flat_washer_remaining(self) -> float:
|
||||
"""平垫剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FLAT_WASHER_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取平垫余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_negative_shell_remaining(self) -> float:
|
||||
"""负极壳剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取负极壳余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_spring_washer_remaining(self) -> float:
|
||||
"""弹垫剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_SPRING_WASHER_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取弹垫余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_finished_battery_remaining_capacity(self) -> float:
|
||||
"""成品电池剩余可容纳数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取成品电池余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_finished_battery_ng_remaining_capacity(self) -> float:
|
||||
"""成品电池NG槽剩余可容纳数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取成品电池NG槽余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
# @property
|
||||
# def data_stack_vision_code(self) -> int:
|
||||
# """物料堆叠复检图片编码 (INT16)"""
|
||||
@@ -1086,7 +925,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
# 步骤0: 前置条件检查
|
||||
logger.info("\n【步骤 0/4】前置条件检查...")
|
||||
self._ensure_modbus_connected()
|
||||
try:
|
||||
# 检查 REG_UNILAB_INTERACT (应该为False,表示使用Unilab交互)
|
||||
unilab_interact_node = self.client.use_node('REG_UNILAB_INTERACT')
|
||||
@@ -1147,42 +985,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
logger.info(" ✓ COIL_GB_L_IGNORE_CMD 检查通过 (值为False,使用左手套箱)")
|
||||
|
||||
# 检查握手寄存器残留(正常初始状态均应为False)
|
||||
# 若上次运行意外断网,这些Unilab侧COIL可能被遗留为True,导致PLC逻辑卡死
|
||||
handshake_checks = [
|
||||
("COIL_UNILAB_SEND_MSG_SUCC_CMD", "Unilab→PLC 配方发送完毕", "上次配方握手未正常复位,PLC可能处于等待配方的卡死状态"),
|
||||
("COIL_UNILAB_REC_MSG_SUCC_CMD", "Unilab→PLC 数据接收完毕", "上次数据接收握手未正常复位"),
|
||||
("UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM", "Unilab→PLC 瓶数发送完毕", "上次瓶数握手未正常复位"),
|
||||
("UNILAB_SEND_FINISHED_CMD", "Unilab→PLC 一组完成确认", "上次完成握手未正常复位"),
|
||||
("COIL_REQUEST_REC_MSG_STATUS", "PLC→Unilab 请求接收配方", "PLC正处于等待配方状态,设备流程已卡死,需重启PLC或手动复位握手"),
|
||||
("COIL_REQUEST_SEND_MSG_STATUS", "PLC→Unilab 请求发送测试数据", "PLC正处于等待发送数据状态,设备流程已卡死"),
|
||||
]
|
||||
for coil_name, coil_desc, stuck_reason in handshake_checks:
|
||||
try:
|
||||
hs_node = self.client.use_node(coil_name)
|
||||
hs_value, hs_err = hs_node.read(1)
|
||||
if hs_err:
|
||||
logger.warning(f" ⚠ 无法读取 {coil_name},跳过此项检查")
|
||||
continue
|
||||
hs_actual = hs_value[0] if isinstance(hs_value, (list, tuple)) else hs_value
|
||||
logger.info(f" {coil_name} 当前值: {hs_actual}")
|
||||
if hs_actual:
|
||||
error_msg = (
|
||||
"❌ 前置握手寄存器检查失败!\n"
|
||||
f" {coil_name} = True (期望值: False)\n"
|
||||
f" 含义: {coil_desc}\n"
|
||||
f" 原因: {stuck_reason}\n"
|
||||
" 建议: 检查上次运行是否意外中断,手动将该寄存器置为False后重试"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
logger.info(f" ✓ {coil_name} 检查通过 (值为False)")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as hs_e:
|
||||
logger.warning(f" ⚠ 检查 {coil_name} 时发生异常: {hs_e},跳过此项")
|
||||
|
||||
logger.info("✓ 所有前置条件检查通过!")
|
||||
|
||||
except ValueError as e:
|
||||
@@ -1356,8 +1158,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
lvbodian: bool = True,
|
||||
battery_pressure_mode: bool = True,
|
||||
battery_clean_ignore: bool = False,
|
||||
file_path: str = "/Users/sml/work",
|
||||
formulations: List[Dict] = None
|
||||
file_path: str = "/Users/sml/work"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发送瓶数+简化组装函数(适用于第二批次及后续批次)
|
||||
@@ -1384,77 +1185,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
battery_pressure_mode: 是否启用压力模式
|
||||
battery_clean_ignore: 是否忽略电池清洁
|
||||
file_path: 实验记录保存路径
|
||||
formulations: 配方信息列表(从 create_orders.mass_ratios 获取)
|
||||
包含 orderCode, target_mass_ratio, real_mass_ratio 等
|
||||
用于CSV数据追溯,可选参数
|
||||
|
||||
Returns:
|
||||
dict: 包含组装结果的字典
|
||||
|
||||
注意:
|
||||
注意:
|
||||
- 第一次启动需先调用 func_pack_device_init_auto_start_combined()
|
||||
- 后续批次直接调用此函数即可
|
||||
"""
|
||||
logger.info("=" * 60)
|
||||
logger.info("开始发送瓶数+简化组装流程...")
|
||||
logger.info(f"电解液瓶数: {elec_num}, 每瓶电池数: {elec_use_num}")
|
||||
|
||||
# 存储配方信息到设备状态(供 CSV 写入使用)
|
||||
if formulations:
|
||||
logger.info(f"接收到配方信息: {len(formulations)} 条")
|
||||
# 将配方信息按 orderCode 索引,方便后续查找
|
||||
self._formulations_map = {
|
||||
f["orderCode"]: f for f in formulations
|
||||
} if formulations else {}
|
||||
# ✅ 新增:存储配方列表(按接收顺序),用于索引访问(兜底用)
|
||||
self._formulations_list = formulations
|
||||
# ✅ 新增:按分液瓶条码(vial_bottle_barcodes)反向索引配方
|
||||
# 配液站夹爪取放顺序与扣电站夹取顺序可能不同,所以不能再依赖位置序号,
|
||||
# 必须用扣电站扫码得到的 data_electrolyte_code 去对齐配液站登记的瓶条码。
|
||||
# vial_bottle_barcodes 字段可能形如 "LG100114"(单瓶)或 '["LG100114","LG100115"]'(多瓶)。
|
||||
self._formulations_by_vial_barcode: Dict[str, Dict] = {}
|
||||
for f in formulations:
|
||||
raw_barcodes = f.get("vial_bottle_barcodes", "")
|
||||
if not raw_barcodes:
|
||||
continue
|
||||
barcodes: List[str] = []
|
||||
if isinstance(raw_barcodes, list):
|
||||
barcodes = [str(b).strip() for b in raw_barcodes if b]
|
||||
else:
|
||||
s = str(raw_barcodes).strip()
|
||||
if s.startswith("[") and s.endswith("]"):
|
||||
try:
|
||||
parsed = json.loads(s)
|
||||
if isinstance(parsed, list):
|
||||
barcodes = [str(b).strip() for b in parsed if b]
|
||||
else:
|
||||
barcodes = [str(parsed).strip()]
|
||||
except Exception:
|
||||
barcodes = [s]
|
||||
else:
|
||||
barcodes = [s]
|
||||
for bc in barcodes:
|
||||
if bc and bc not in self._formulations_by_vial_barcode:
|
||||
self._formulations_by_vial_barcode[bc] = f
|
||||
logger.info(
|
||||
f"已建立分液瓶条码 → 配方索引: {len(self._formulations_by_vial_barcode)} 条 "
|
||||
f"(条码: {list(self._formulations_by_vial_barcode.keys())})"
|
||||
)
|
||||
else:
|
||||
logger.warning("未接收到配方信息,CSV将不包含配方字段")
|
||||
self._formulations_map = {}
|
||||
self._formulations_list = []
|
||||
self._formulations_by_vial_barcode = {}
|
||||
|
||||
# ✅ 新增:存储每瓶电池数,用于计算当前使用的瓶号
|
||||
# ⚠️ 确保转换为整数(前端可能传递字符串)
|
||||
self._elec_use_num = int(elec_use_num) if elec_use_num else 0
|
||||
logger.info(f"已存储参数: 每瓶电池数={self._elec_use_num}, 配方数={len(self._formulations_list)}")
|
||||
|
||||
# ✅ 新增:软件层电池计数器(防止硬件计数器不准确)
|
||||
self._software_battery_counter = 0 # 从0开始,每写入一次CSV递增
|
||||
logger.info("软件层电池计数器已初始化")
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 步骤1: 发送电解液瓶数(触发物料搬运)
|
||||
@@ -1590,8 +1331,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
data_assembly_time = self.data_assembly_time
|
||||
data_assembly_pressure = self.data_assembly_pressure
|
||||
data_electrolyte_volume = self.data_electrolyte_volume
|
||||
data_coin_type = self.data_coin_type # 电池类型(7或8种物料)
|
||||
data_battery_number = self.data_current_assembling_count # ✅ 真正的电池编号
|
||||
data_coin_num = self.data_coin_num
|
||||
|
||||
# 处理电解液二维码 - 确保是字符串类型
|
||||
try:
|
||||
@@ -1621,32 +1361,28 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
logger.debug(f"data_assembly_time: {data_assembly_time}")
|
||||
logger.debug(f"data_assembly_pressure: {data_assembly_pressure}")
|
||||
logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}")
|
||||
logger.debug(f"data_coin_type: {data_coin_type}") # 电池类型
|
||||
logger.debug(f"data_battery_number: {data_battery_number}") # ✅ 电池编号
|
||||
logger.debug(f"data_coin_num: {data_coin_num}")
|
||||
logger.debug(f"data_electrolyte_code: {data_electrolyte_code}")
|
||||
logger.debug(f"data_coin_cell_code: {data_coin_cell_code}")
|
||||
#接收完信息后,读取完毕标志位置True
|
||||
finished_battery_magazine = self.deck.get_resource("成品弹夹")
|
||||
|
||||
# 计算电池应该放在哪个洞,以及洞内的堆叠位置
|
||||
# 成品弹夹有6个洞,每个洞可堆叠20颗电池
|
||||
# 前5个洞(索引0-4)放正常电池,第6个洞(索引5)放NG电池
|
||||
BATTERIES_PER_HOLE = 20
|
||||
MAX_NORMAL_BATTERIES = 100 # 5个洞 × 20颗/洞
|
||||
|
||||
hole_index = self.coin_num_N // BATTERIES_PER_HOLE # 第几个洞(0-4为正常电池)
|
||||
in_hole_position = self.coin_num_N % BATTERIES_PER_HOLE # 洞内的堆叠序号
|
||||
|
||||
if hole_index >= 5:
|
||||
logger.error(f"电池数量超出正常容量范围: {self.coin_num_N + 1} > {MAX_NORMAL_BATTERIES}")
|
||||
raise ValueError(f"成品弹夹正常洞位已满(最多{MAX_NORMAL_BATTERIES}颗),当前尝试放置第{self.coin_num_N + 1}颗")
|
||||
|
||||
target_hole = finished_battery_magazine.children[hole_index] # 获取目标洞
|
||||
liaopan3 = self.deck.get_resource("成品弹夹")
|
||||
|
||||
# 生成唯一的电池名称(使用时间戳确保唯一性)
|
||||
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
battery_name = f"battery_{self.coin_num_N}_{timestamp_suffix}"
|
||||
|
||||
# 检查目标位置是否已有资源,如果有则先卸载
|
||||
target_slot = liaopan3.children[self.coin_num_N]
|
||||
if target_slot.children:
|
||||
logger.warning(f"位置 {self.coin_num_N} 已有资源,将先卸载旧资源")
|
||||
try:
|
||||
# 卸载所有现有子资源
|
||||
for child in list(target_slot.children):
|
||||
target_slot.unassign_child_resource(child)
|
||||
logger.info(f"已卸载旧资源: {child.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"卸载旧资源时出错: {e}")
|
||||
|
||||
# 创建新的电池资源
|
||||
battery = ElectrodeSheet(name=battery_name, size_x=14, size_y=14, size_z=2)
|
||||
battery._unilabos_state = {
|
||||
@@ -1657,12 +1393,13 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"electrolyte_volume": data_electrolyte_volume
|
||||
}
|
||||
|
||||
# 将电池堆叠到目标洞中
|
||||
# 分配新资源到目标位置
|
||||
try:
|
||||
target_hole.assign_child_resource(battery, location=None)
|
||||
logger.info(f"成功放置电池 {battery_name} 到弹夹洞{hole_index}的第{in_hole_position + 1}层 (总计第{self.coin_num_N + 1}颗)")
|
||||
target_slot.assign_child_resource(battery, location=None)
|
||||
logger.info(f"成功分配电池 {battery_name} 到位置 {self.coin_num_N}")
|
||||
except Exception as e:
|
||||
logger.error(f"放置电池资源失败: {e}")
|
||||
logger.error(f"分配电池资源失败: {e}")
|
||||
# 如果分配失败,尝试使用更简单的方法
|
||||
raise
|
||||
|
||||
#print(jipian2.parent)
|
||||
@@ -1683,7 +1420,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
time_date = datetime.now().strftime("%Y%m%d")
|
||||
#秒级时间戳用于标记每一行电池数据
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self._last_assembly_timestamp = timestamp
|
||||
#生成输出文件的变量
|
||||
self.csv_export_file = os.path.join(file_path, f"date_{time_date}.csv")
|
||||
#将数据存入csv文件
|
||||
@@ -1694,107 +1430,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
writer.writerow([
|
||||
'Time', 'open_circuit_voltage', 'pole_weight',
|
||||
'assembly_time', 'assembly_pressure', 'electrolyte_volume',
|
||||
'data_coin_type', 'electrolyte_code', 'coin_cell_code',
|
||||
'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes',
|
||||
'target_mass_ratio', 'real_mass_ratio'
|
||||
'coin_num', 'electrolyte_code', 'coin_cell_code'
|
||||
])
|
||||
#立刻写入磁盘
|
||||
csvfile.flush()
|
||||
#开始追加电池信息
|
||||
with open(self.csv_export_file, 'a', newline='', encoding='utf-8') as csvfile:
|
||||
writer = csv.writer(csvfile)
|
||||
|
||||
# ========== 提取配方信息 ==========
|
||||
formulation_order_name = ""
|
||||
prep_bottle_barcode = ""
|
||||
vial_bottle_barcodes = ""
|
||||
target_ratio_str = ""
|
||||
real_ratio_str = ""
|
||||
|
||||
# 从 self._formulations_list 获取配方信息
|
||||
if hasattr(self, '_formulations_list') and self._formulations_list:
|
||||
# ============================================================
|
||||
# ✅ 主方案:用扣电站扫码得到的电解液瓶条码 (data_electrolyte_code)
|
||||
# 反查配液站登记的 vial_bottle_barcodes,避免依赖夹爪取放顺序。
|
||||
# 配液站和扣电站的瓶子顺序往往不一致(不同自动化设备的取放策略不同),
|
||||
# 按位置序号匹配会错位;但每个瓶子的条码是唯一的,按条码匹配最可靠。
|
||||
# ============================================================
|
||||
formulation = None
|
||||
match_method = ""
|
||||
|
||||
barcode_map = getattr(self, "_formulations_by_vial_barcode", {}) or {}
|
||||
scan_code = (data_electrolyte_code or "").strip()
|
||||
if scan_code and scan_code != "N/A" and barcode_map:
|
||||
formulation = barcode_map.get(scan_code)
|
||||
if formulation is not None:
|
||||
match_method = f"按条码({scan_code})精确匹配"
|
||||
else:
|
||||
logger.warning(
|
||||
f"[CSV写入] 电池 {data_battery_number}: 扫码条码 {scan_code} "
|
||||
f"在配方索引中找不到 (已登记条码: {list(barcode_map.keys())})"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 🔁 降级方案:扫码失败 / 条码缺失时按瓶号位置兜底
|
||||
# 保留原有"每瓶电池数"或"二维码尾号"的位置推断逻辑,
|
||||
# 确保在异常路径下仍能落盘(位置推断的结果可能不准,仅供回溯)。
|
||||
# ============================================================
|
||||
if formulation is None:
|
||||
if hasattr(self, '_elec_use_num') and self._elec_use_num:
|
||||
elec_use_num_int = int(self._elec_use_num) if self._elec_use_num else 1
|
||||
if elec_use_num_int > 0:
|
||||
current_bottle_index = (data_battery_number - 1) // elec_use_num_int
|
||||
else:
|
||||
current_bottle_index = 0
|
||||
|
||||
logger.debug(
|
||||
f"[CSV写入] 电池 {data_battery_number}: 降级按瓶号索引={current_bottle_index} "
|
||||
f"(每瓶{self._elec_use_num}颗电池)"
|
||||
)
|
||||
else:
|
||||
current_bottle_index = (
|
||||
int(data_electrolyte_code.split('-')[-1])
|
||||
if '-' in str(data_electrolyte_code)
|
||||
else 0
|
||||
)
|
||||
logger.debug(
|
||||
f"[CSV写入] 电池 {data_battery_number}: 降级按二维码尾号瓶号索引={current_bottle_index}"
|
||||
)
|
||||
|
||||
if 0 <= current_bottle_index < len(self._formulations_list):
|
||||
formulation = self._formulations_list[current_bottle_index]
|
||||
match_method = f"按位置兜底匹配[{current_bottle_index}]"
|
||||
else:
|
||||
logger.warning(
|
||||
f"[CSV写入] 电池 {data_battery_number}: 瓶号索引 {current_bottle_index} "
|
||||
f"超出配方列表范围 (共{len(self._formulations_list)}个配方)"
|
||||
)
|
||||
|
||||
if formulation is not None:
|
||||
formulation_order_name = formulation.get("orderName", "")
|
||||
prep_bottle_barcode = formulation.get("prep_bottle_barcode", "")
|
||||
vial_bottle_barcodes = formulation.get("vial_bottle_barcodes", "")
|
||||
|
||||
real_ratio = formulation.get("real_mass_ratio", {})
|
||||
target_ratio = formulation.get("target_mass_ratio", {})
|
||||
|
||||
target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else ""
|
||||
real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else ""
|
||||
|
||||
logger.info(
|
||||
f"[CSV写入] 电池 {data_battery_number} ({match_method}): "
|
||||
f"orderName={formulation_order_name}, 配液瓶={prep_bottle_barcode}, "
|
||||
f"分液瓶={vial_bottle_barcodes}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"[CSV写入] 电池 {data_battery_number}: 未找到配方信息数据")
|
||||
|
||||
writer.writerow([
|
||||
timestamp, data_open_circuit_voltage, data_pole_weight,
|
||||
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
|
||||
data_coin_type, data_electrolyte_code, data_coin_cell_code,
|
||||
formulation_order_name, prep_bottle_barcode, vial_bottle_barcodes,
|
||||
target_ratio_str, real_ratio_str
|
||||
data_coin_num, data_electrolyte_code, data_coin_cell_code
|
||||
])
|
||||
#立刻写入磁盘
|
||||
csvfile.flush()
|
||||
@@ -1939,18 +1585,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
pole_weight = 0.0
|
||||
|
||||
battery_info = {
|
||||
"Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
|
||||
"battery_index": coin_num_N + 1,
|
||||
"battery_barcode": battery_qr_code,
|
||||
"electrolyte_barcode": electrolyte_qr_code,
|
||||
"open_circuit_voltage": open_circuit_voltage,
|
||||
"pole_weight": pole_weight,
|
||||
"assembly_time": self.data_assembly_time,
|
||||
"assembly_pressure": self.data_assembly_pressure,
|
||||
"electrolyte_volume": self.data_electrolyte_volume,
|
||||
"data_coin_type": getattr(self, "data_coin_type", 0),
|
||||
"electrolyte_code": electrolyte_qr_code,
|
||||
"coin_cell_code": battery_qr_code,
|
||||
"electrolyte_volume": self.data_electrolyte_volume
|
||||
}
|
||||
battery_data_list.append(battery_info)
|
||||
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}")
|
||||
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}")
|
||||
|
||||
time.sleep(1)
|
||||
# TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑
|
||||
@@ -1979,7 +1624,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"success": True,
|
||||
"total_batteries": len(battery_data_list),
|
||||
"batteries": battery_data_list,
|
||||
"assembly_data": battery_data_list,
|
||||
"summary": {
|
||||
"electrolyte_bottles_used": elec_num,
|
||||
"batteries_per_bottle": elec_use_num,
|
||||
@@ -2023,7 +1667,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
file_path: str = "/Users/sml/work"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
简化版电池组装函数,整合了原 qiming_coin_cell_code 的参数设置和双滴模式
|
||||
|
||||
此函数是 func_allpack_cmd 的增强版本,自动处理以下配置:
|
||||
- 负极片和隔膜的盘数及矩阵点位
|
||||
- 枪头盒矩阵点位
|
||||
@@ -2194,18 +1839,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
pole_weight = 0.0
|
||||
|
||||
battery_info = {
|
||||
"Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
|
||||
"battery_index": coin_num_N + 1,
|
||||
"battery_barcode": battery_qr_code,
|
||||
"electrolyte_barcode": electrolyte_qr_code,
|
||||
"open_circuit_voltage": open_circuit_voltage,
|
||||
"pole_weight": pole_weight,
|
||||
"assembly_time": self.data_assembly_time,
|
||||
"assembly_pressure": self.data_assembly_pressure,
|
||||
"electrolyte_volume": self.data_electrolyte_volume,
|
||||
"data_coin_type": getattr(self, "data_coin_type", 0),
|
||||
"electrolyte_code": electrolyte_qr_code,
|
||||
"coin_cell_code": battery_qr_code,
|
||||
"electrolyte_volume": self.data_electrolyte_volume
|
||||
}
|
||||
battery_data_list.append(battery_info)
|
||||
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}")
|
||||
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
@@ -2232,7 +1876,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"success": True,
|
||||
"total_batteries": len(battery_data_list),
|
||||
"batteries": battery_data_list,
|
||||
"assembly_data": battery_data_list,
|
||||
"summary": {
|
||||
"electrolyte_bottles_used": elec_num,
|
||||
"batteries_per_bottle": elec_use_num,
|
||||
@@ -2279,7 +1922,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
def fun_wuliao_test(self) -> bool:
|
||||
#找到data_init中构建的2个物料盘
|
||||
test_battery_plate = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
|
||||
liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
|
||||
for i in range(16):
|
||||
battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2)
|
||||
battery._unilabos_state = {
|
||||
@@ -2289,7 +1932,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"electrolyte_volume": 20.0,
|
||||
"electrolyte_name": f"DP{i}"
|
||||
}
|
||||
test_battery_plate.children[i].assign_child_resource(battery, location=None)
|
||||
liaopan3.children[i].assign_child_resource(battery, location=None)
|
||||
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
@@ -2332,7 +1975,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
data_assembly_time = self.data_assembly_time
|
||||
data_assembly_pressure = self.data_assembly_pressure
|
||||
data_electrolyte_volume = self.data_electrolyte_volume
|
||||
data_coin_type = self.data_coin_type # 电池类型(7或8种物料)
|
||||
data_coin_num = self.data_coin_num
|
||||
data_electrolyte_code = self.data_electrolyte_code
|
||||
data_coin_cell_code = self.data_coin_cell_code
|
||||
# 电解液瓶位置
|
||||
@@ -2446,7 +2089,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
writer.writerow([
|
||||
timestamp, data_open_circuit_voltage, data_pole_weight,
|
||||
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
|
||||
data_coin_type, data_electrolyte_code, data_coin_cell_code # ✅ 已修正
|
||||
data_coin_num, data_electrolyte_code, data_coin_cell_code
|
||||
])
|
||||
#立刻写入磁盘
|
||||
csvfile.flush()
|
||||
@@ -2497,7 +2140,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 简单测试
|
||||
workstation = CoinCellAssemblyWorkstation(deck=yihua_coin_cell_deck(name="coin_cell_deck"))
|
||||
workstation = CoinCellAssemblyWorkstation(deck=CoincellDeck(setup=True, name="coin_cell_deck"))
|
||||
# workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False)
|
||||
# print(f"工作站创建成功: {workstation.deck.name}")
|
||||
# print(f"料盘数量: {len(workstation.deck.children)}")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
||||
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
||||
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
||||
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
||||
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
||||
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
||||
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
||||
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
||||
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
||||
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
||||
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
||||
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
||||
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
||||
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
||||
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
||||
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
||||
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
||||
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
||||
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
|
||||
,,,,,coil,8320,
|
||||
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
|
||||
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
|
||||
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
|
||||
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
|
||||
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
|
||||
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
|
||||
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
|
||||
,BOOL,,,,coil,8300,
|
||||
,BOOL,,,,coil,8310,
|
||||
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
|
||||
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
|
||||
,BOOL,,,,coil,8350,
|
||||
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
|
||||
,BOOL,,,,coil,8380,
|
||||
,BOOL,,,,coil,8390,
|
||||
,BOOL,,,,coil,8400,
|
||||
,BOOL,,,,coil,8410,
|
||||
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
|
||||
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
|
||||
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
|
||||
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
|
||||
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
||||
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
||||
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
||||
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
|
||||
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
|
||||
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
|
||||
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
|
||||
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
|
||||
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
|
||||
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
|
||||
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
|
||||
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
|
||||
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
|
||||
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
|
||||
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
|
||||
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
|
||||
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
|
||||
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
|
||||
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
|
||||
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
|
||||
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
|
||||
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
|
||||
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
|
||||
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
|
||||
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
|
||||
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
|
||||
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
|
||||
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
|
||||
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
|
||||
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
|
||||
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
|
||||
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
|
||||
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
|
||||
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
|
||||
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
|
||||
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
|
||||
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制
|
||||
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
|
||||
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败
|
||||
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败
|
||||
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败
|
||||
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败
|
||||
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
|
||||
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
|
||||
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
|
||||
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
|
||||
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
|
||||
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
|
||||
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
|
||||
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
|
||||
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
|
||||
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
|
||||
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
|
||||
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
|
||||
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
|
||||
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
|
||||
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
|
||||
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
|
||||
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
|
||||
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
|
||||
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
|
||||
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
|
||||
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
|
||||
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
|
||||
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
|
||||
|
@@ -1,88 +0,0 @@
|
||||
# 物料系统标准化重构方案
|
||||
|
||||
根据开发者的反馈,本方案旨在遵循“标准化而非绕过”的原则,对资源类(Deck、Carrier、Magazine)进行重构。核心目标是将物理结构的初始化与物料/极片的初始填充逻辑解耦,彻底解决反序列化过程中的初始化冲突。
|
||||
|
||||
## 拟议变更
|
||||
|
||||
### [参考] PRCXI9300 标准化模式
|
||||
#### [参考文件] [prcxi.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/prcxi.py)
|
||||
* **PRCXI9300Deck**: 演示了如何在 `serialize` 中导出 `sites` 元数据,以及如何在 `assign_child_resource` 中实现稳健的槽位匹配(支持按名称、坐标或索引匹配)。
|
||||
* **PRCXI9300Container**: 演示了标准的 `load_state` 和 `serialize_state` 模式,确保业务状态(如 `Material` UUID)能正确往返序列化。
|
||||
|
||||
### [组件] 台面 (Decks)
|
||||
#### [修改] [decks.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/bioyond/decks.py)
|
||||
* 将 `BIOYOND_YB_Deck` 重命名为 **`BioyondElectrolyteDeck`**,对应工厂函数 `YB_Deck()` 重命名为 **`bioyond_electrolyte_deck()`**。
|
||||
* `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` **保持不变**。
|
||||
* 以上三个 Deck 的 `__init__` 中均移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
|
||||
|
||||
#### [修改 + 重命名] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) → `yihua_coin_cell_materials.py`
|
||||
* 将 `CoincellDeck` 重命名为 **`YihuaCoinCellDeck`**,对应工厂函数 `YH_Deck()` 重命名为 **`yihua_coin_cell_deck()`**。
|
||||
* 从 `YihuaCoinCellDeck.__init__` 中移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
|
||||
|
||||
### [组件] 容器类与弹夹 (Itemized Carriers & Magazines)
|
||||
#### [修改] [magazine.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/battery/magazine.py)
|
||||
* 重构 `magazine_factory`:将创建 `MagazineHolder` 几何结构(空槽位)的过程与填充 `ElectrodeSheet` 物料的过程分离。
|
||||
* 确保 `MagazineHolder` 和 `Magazine` 的 `__init__` 过程中不主动创建任何内容物。
|
||||
|
||||
#### [修改] [warehouse.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/warehouse.py)
|
||||
* 确保 `WareHouse` 类和 `warehouse_factory` 遵循相同模式:先初始化几何结构,内容物另行处理。
|
||||
|
||||
#### [修改] [itemized_carrier.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/itemized_carrier.py)
|
||||
* 移除之前添加的 `idx is None` 兜底补丁。
|
||||
* 修复命名规范,确保 `assign_child_resource` 在反序列化时能准确匹配资源。
|
||||
|
||||
### [组件] 状态兼容性 (State Compatibility)
|
||||
#### [修改] [resource_tracker.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/resource_tracker.py)
|
||||
* 在 `to_plr_resources` 方法中调用 `load_all_state` 之前,预处理 `all_states` 字典。
|
||||
* 对于 `Container` 类型的资源,如果其状态中缺少 `liquid_history` 或 `pending_liquids` 等 PLR 新版本要求的键,则填充默认值(如空列表/字典),防止反序列化中断。
|
||||
|
||||
### [组件] 料盘 (Material Plates)
|
||||
#### [修改] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py)
|
||||
* 重构 `MaterialPlate`:不在 `__init__` 中直接调用 `create_ordered_items_2d`。
|
||||
* 重构 `YIHUA_Electrolyte_12VialCarrier`:将其修改为标准的基类定义或在工厂方法中彻底剥离内部 12 个 `YB_pei_ye_xiao_Bottle` 的强制初始化,以防反序列化冲突。
|
||||
|
||||
### [组件] 跨站转运与分液瓶板 (Vial Plate Transfer)
|
||||
#### [修改] [bioyond_cell_workstation.py] & [YB_YH_materials.py]
|
||||
* **分析**:目前的 `bioyond_cell_workstation.py` 在执行转移时,是用 `sites=["electrolyte_buffer"]` 试图把整块 `YB_Vial_5mL_Carrier` 板转移给目标。但由于实际工艺中,配液站将分液瓶板传往扣电工站后,是由扣电工站的机械臂**逐瓶抓取**并放入内部的 `bottle_rack_6x2`(电解液缓存位),用完后再放入 `bottle_rack_6x2_2`(废液位),因此配液站的这一次“跨工位资源树转移”在逻辑上存在偏差:目标槽位不应该是装单瓶的载体 `bottle_rack`。
|
||||
* **修复方案**:
|
||||
1. **目标端 (Yihua 侧)**:
|
||||
* 在 `YB_YH_materials.py` 中为从配液站传过来的“分液瓶板”本身设置一个接驳专用的 `PlateSlot`(或者单纯直接移到 Deck 指定坐标)。这个位置负责真正在资源树层级上合法接收配液站传过来的完整 Board。
|
||||
* 重构 `YIHUA_Electrolyte_12VialCarrier`:为了防止初始化反序列化冲突,取消内部在 `__init__` 中自动填充满 12 个 `YB_pei_ye_xiao_Bottle` 实例的逻辑。`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 初始化时均应为空。
|
||||
2. **转运端 (Bioyond 侧)**:
|
||||
* 修改 `bioyond_cell_workstation.py` 的资源树数字转运代码,将其转移目标对应到 Yihua 侧新设立的“分液瓶板接驳区域”资源,或者干脆只更新资源树坐标位置(使其脱离 Bioyond Deck 加入 Yihua Deck),而不再强行挂载到一个无法容纳 Carrier 的 `bottle_rack_6x2` 内部。
|
||||
|
||||
### [组件] 依华扣电组装工站物料余量监控 (Material Monitoring)
|
||||
#### [修改] 寄存器直读与前端集成
|
||||
* **物理对象保留但虚化追踪**:原有的实体台面对象(如 `MaterialPlate`、`MagazineHolder` 各类型及其对应的洞位坐标)**仍然保留并使用**。保留它们是为了给机器臂提供基础的物理空间取放标定,以及作为前端页面的可视和可交互区块。
|
||||
* **内部物料免追踪**:既然余量完全由寄存器接管,**我们将不再在这些弹夹或洞位内部显式生成、塞入和追踪每一个具体的极片或外壳对象 (如 `ElectrodeSheet` 等)**。这恰好与我们的重构主旨(不主动在 `__init__` 建子物料以避开反序列化冲突)完美结合,进一步极大地减轻了后台资源树对象的复杂度。
|
||||
* **监控方式变更**:放弃现有的物料余量方式,直接读取依华扣电组装工站开放的寄存器地址以获取准确余量。
|
||||
* **前端界面集成**:在前端界面点击负极壳、弹垫片等弹夹的 data view 时,直接读取并显示寄存器中的各自余量。
|
||||
* **新增寄存器映射** (参考 `coin_cell_assembly_b.csv`):
|
||||
* `10mm正极片剩余物料数量(R)`:`read hold_register 520` (REAL)
|
||||
* `12mm正极片剩余物料数量(R)`:`read hold_register 522` (REAL)
|
||||
* `16mm正极片剩余物料数量(R)`:`read hold_register 524` (REAL)
|
||||
* `铝箔剩余物料数量(R)`:`read hold_register 526` (REAL)
|
||||
* `正极壳剩余物料数量(R)`:`read hold_register 528` (REAL)
|
||||
* `平垫剩余物料数量(R)`:`read hold_register 530` (REAL)
|
||||
* `负极壳剩余物料数量(R)`:`read hold_register 532` (REAL)
|
||||
* `弹垫剩余物料数量(R)`:`read hold_register 534` (REAL)
|
||||
* `成品电池剩余可容纳数量(R)`:`read hold_register 536` (REAL)
|
||||
* `成品电池NG槽剩余可容纳数量(R)`:`read hold_register 538` (REAL)
|
||||
|
||||
### [配置] JSON 配置文件 (Configuration Files)
|
||||
#### [修改] 资源类型名称更新
|
||||
* 更新以下配置文件,将其中的 `BIOYOND_YB_Deck` 替换为新的类名 **`BioyondElectrolyteDeck`**,以及将 `coin_cell_deck` 替换为 **`YihuaCoinCellDeck`**:
|
||||
* `yibin_electrolyte_config.json`
|
||||
* `yibin_coin_cell_only_config.json`
|
||||
* `yibin_electrolyte_only_config.json`
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 自动化测试
|
||||
* 对重构后的类运行 `pylabrobot` 序列化/反序列化测试,确保状态能够完美恢复。
|
||||
* 检查各工作站节点启动时是否仍存在 `ValueError: Resource '...' already assigned to deck` 报错。
|
||||
* 检查 `resource_tracker` 中是否仍存在重复 UUID 报错。
|
||||
|
||||
### 手动验证
|
||||
* 重启各工作站节点,验证资源树是否能根据数据库数据正确还还原。
|
||||
* 验证“自动”与“手动”传输窗资源在台面上的分配是否正确。
|
||||
@@ -1,388 +0,0 @@
|
||||
# 物料系统标准化重构方案 v2(增强版)
|
||||
|
||||
> **基于原始方案 (`implementation_plan.md`) 的补充与细化**。
|
||||
> 本文档在原方案基础上:①增加当前代码现状核查结果;②明确各任务的执行顺序与文件级改动;③新增注意事项与回归测试命令。
|
||||
|
||||
---
|
||||
|
||||
## 0. 核心原则(保持不变)
|
||||
|
||||
"**物理几何结构初始化(Deck / Carrier / Magazine 的 `__init__`)与物料内容物填充(`setup()` / `klasses` 参数)必须彻底解耦**",以消除 PLR 反序列化时的 `Resource already assigned to deck` 错误。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前代码现状核查(2026-03-12)
|
||||
|
||||
| 文件 | 计划要求 | 当前状态 | 是否完成 |
|
||||
|---|---|---|---|
|
||||
| `resources/bioyond/decks.py` | 重命名类;移除 `setup` 参数和 `deserialize` 补丁 | 仍是 `BIOYOND_YB_Deck`;`setup` 参数和 `deserialize` 均存在 | ❌ |
|
||||
| `coin_cell_assembly/YB_YH_materials.py` | 重命名类;文件迁移;移除补丁 | 仍是 `CoincellDeck`;`setup` 参数和 `deserialize` 均存在 | ❌ |
|
||||
| `resources/battery/magazine.py` | `magazine_factory` 不主动填充物料 | `MagazineHolder_6_Cathode` / `_6_Anode` / `_4_Cathode` 仍传 `klasses`,初始化时填满极片 | ❌ |
|
||||
| `resources/battery/bottle_carriers.py` | `YIHUA_Electrolyte_12VialCarrier` 初始化时不填充瓶子 | 第 54-55 行仍循环填充 12 个 `YB_pei_ye_xiao_Bottle` | ❌ |
|
||||
| `resources/itemized_carrier.py` | 移除 `idx is None` 兜底补丁 | 第 182-190 行仍保留该兜底逻辑 | ❌(待前置任务完成后移除) |
|
||||
| `resources/resource_tracker.py` | `load_all_state` 前预填 `Container` 缺失键 | 第 616 行直接调用,无预处理 | ❌ |
|
||||
| `bioyond_cell_workstation.py` | 修正跨站转运目标为合法接驳槽 | 第 1563 行仍 `sites=["electrolyte_buffer"]`,目标 UUID 为硬编码虚拟资源 | ❌ |
|
||||
| `yibin_*.json` 配置文件 | 更新类名 | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌ |
|
||||
| `registry/resources/bioyond/deck.yaml` | 更新类名(原计划未提及) | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌(**新增**) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 执行顺序(含依赖关系)
|
||||
|
||||
```
|
||||
阶段 A(底层资源类)
|
||||
A1. magazine.py — 移除 klasses 填充
|
||||
A2. bottle_carriers.py — 移除瓶子填充
|
||||
|
||||
阶段 B(Deck 层)
|
||||
B1. decks.py — 移除 setup 参数和 deserialize 补丁;重命名
|
||||
B2. YB_YH_materials.py → 重命名;移除 CoincellDeck 的 setup 参数和 deserialize 补丁
|
||||
|
||||
阶段 C(状态兼容)
|
||||
C1. resource_tracker.py — 预填 Container 缺失键
|
||||
C2. itemized_carrier.py — 移除 idx is None 兜底补丁(B 阶段完成后)
|
||||
|
||||
阶段 D(跨站转运修复)
|
||||
D1. YB_YH_materials.py 新增 vial_plate_dock(接驳专用槽)
|
||||
D2. bioyond_cell_workstation.py 修正 transfer 目标
|
||||
|
||||
阶段 E(配置与注册表)
|
||||
E1. yibin_*.json 更新类名
|
||||
E2. registry/resources/bioyond/deck.yaml 更新类名
|
||||
E3. coin_cell_assembly.py 更新导入路径(若文件重命名)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 分阶段详细说明
|
||||
|
||||
---
|
||||
|
||||
### 阶段 A — 底层资源类
|
||||
|
||||
#### A1. `unilabos/resources/battery/magazine.py`
|
||||
|
||||
**问题**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 在调用 `magazine_factory` 时传入 `klasses`,导致每次 `__init__` 就填满极片,反序列化时重复添加。
|
||||
|
||||
**修改**:
|
||||
|
||||
- 将三个函数中的 `klasses=[...]` 改为 `klasses=None`(与 `MagazineHolder_6_Battery` 保持一致)。
|
||||
- **理由**:物料余量已由寄存器管理(见阶段 F),不需要在资源树中追踪每一个极片。
|
||||
|
||||
```python
|
||||
# 修改前(MagazineHolder_6_Cathode 举例)
|
||||
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
|
||||
|
||||
# 修改后
|
||||
klasses=None,
|
||||
```
|
||||
|
||||
> **注意**:`magazine_factory` 中 `klasses` 参数及循环体代码保留(仍可按需在非序列化场景使用),只是各具体工厂函数不再传入。
|
||||
|
||||
---
|
||||
|
||||
#### A2. `unilabos/resources/battery/bottle_carriers.py`
|
||||
|
||||
**问题**:`YIHUA_Electrolyte_12VialCarrier` 第 54-55 行在工厂函数末尾循环填充 12 个瓶子。
|
||||
|
||||
**修改**:删除以下两行:
|
||||
|
||||
```python
|
||||
# 删除
|
||||
for i in range(12):
|
||||
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
|
||||
```
|
||||
|
||||
**理由**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 均应初始化为空,瓶子由 Bioyond 侧实际转运后再填入。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 B — Deck 层重构
|
||||
|
||||
#### B1. `unilabos/resources/bioyond/decks.py`
|
||||
|
||||
**改动列表**:
|
||||
|
||||
1. **重命名** `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`
|
||||
2. **重命名** `YB_Deck()` 工厂函数 → `bioyond_electrolyte_deck()`
|
||||
3. **移除** `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用
|
||||
4. **删除** `deserialize` 方法重写(该临时补丁在 `setup` 参数移除后自然失效,继续保留反而掩盖问题)
|
||||
5. `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` 同步执行第 3、4 步
|
||||
|
||||
**重构后初始化模式**:
|
||||
|
||||
```python
|
||||
class BioyondElectrolyteDeck(Deck):
|
||||
def __init__(self, name: str = "YB_Deck", ...):
|
||||
super().__init__(name=name, ...)
|
||||
# ❌ 不调用 self.setup()
|
||||
# PLR 反序列化时只会调用 __init__,然后从 children JSON 重建子资源
|
||||
|
||||
def setup(self) -> None:
|
||||
# 完整的子资源初始化逻辑保留在这里,只由工厂函数调用
|
||||
...
|
||||
|
||||
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
|
||||
deck = BioyondElectrolyteDeck(name=name)
|
||||
deck.setup() # ✅ 工厂函数负责填充
|
||||
return deck
|
||||
```
|
||||
|
||||
**同步修改**:
|
||||
- `bioyond_cell_workstation.py` 第 20 行:
|
||||
```python
|
||||
# 修改前
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck
|
||||
# 修改后
|
||||
from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck
|
||||
```
|
||||
- 同文件第 2440 行:`BIOYOND_YB_Deck(setup=True)` → `bioyond_electrolyte_deck(name="YB_Deck")`
|
||||
|
||||
---
|
||||
|
||||
#### B2. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
|
||||
|
||||
**改动列表**:
|
||||
|
||||
1. **重命名** `CoincellDeck` → `YihuaCoinCellDeck`
|
||||
2. **重命名** `YH_Deck()` → `yihua_coin_cell_deck()`(可保留 `YH_Deck` 作为兼容别名,日后废弃)
|
||||
3. **移除** `CoincellDeck.__init__` 中 `setup: bool = False` 参数及调用
|
||||
4. **删除** `CoincellDeck.deserialize` 重写方法
|
||||
5. `MaterialPlate.__init__` 中移除 `fill` 参数,始终不主动调用 `create_ordered_items_2d`(当前 `fill=False` 路径已正确,只需删除 `fill=True` 分支)
|
||||
|
||||
```python
|
||||
# 修改前(MaterialPlate.__init__ 片段)
|
||||
if fill:
|
||||
super().__init__(..., ordered_items=holes, ...)
|
||||
else:
|
||||
super().__init__(..., ordered_items=ordered_items, ...)
|
||||
|
||||
# 修改后(始终走 "不填充" 路径)
|
||||
super().__init__(..., ordered_items=ordered_items, ...)
|
||||
# holes 的创建代码整体移入独立工厂方法
|
||||
```
|
||||
|
||||
**同步修改**:
|
||||
- `coin_cell_assembly.py` 第 20 行导入:
|
||||
```python
|
||||
# 修改前
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
|
||||
# 修改后
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck
|
||||
```
|
||||
- 同文件第 2245 行:`CoincellDeck(setup=True, name="coin_cell_deck")` → `yihua_coin_cell_deck(name="coin_cell_deck")`
|
||||
- 文件重命名(可选):`YB_YH_materials.py` → `yihua_coin_cell_materials.py`(若重命名,所有 import 路径需全局替换)
|
||||
|
||||
---
|
||||
|
||||
### 阶段 C — 状态兼容
|
||||
|
||||
#### C1. `unilabos/resources/resource_tracker.py`
|
||||
|
||||
**问题**:第 616 行直接调用 `plr_resource.load_all_state(all_states)`,若 `Container` 类资源的 `data` 字段缺少 `liquid_history` 或 `pending_liquids`,PLR 新版本会抛出 `KeyError`。
|
||||
|
||||
**修改**:在第 616 行前插入预处理:
|
||||
|
||||
```python
|
||||
# 在 load_all_state 调用前预填缺失键
|
||||
from pylabrobot.resources.container import Container as PLRContainer
|
||||
for res_name, state in all_states.items():
|
||||
if state and isinstance(state, dict):
|
||||
# Container 类型要求这两个键存在
|
||||
state.setdefault("liquid_history", [])
|
||||
state.setdefault("pending_liquids", {})
|
||||
|
||||
plr_resource.load_all_state(all_states)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### C2. `unilabos/resources/itemized_carrier.py`
|
||||
|
||||
**前提**:B1、B2 阶段完成,Deck 类名与资源命名规范已对齐后再执行此步。
|
||||
|
||||
**修改**:删除第 182-190 行的兜底补丁:
|
||||
|
||||
```python
|
||||
# 删除以下整个 if 块
|
||||
if idx is None:
|
||||
fallback_location = location if location is not None else Coordinate.zero()
|
||||
super().assign_child_resource(resource, location=fallback_location, reassign=reassign)
|
||||
return
|
||||
```
|
||||
|
||||
**替代**:改为抛出带诊断信息的异常,便于后续问题排查:
|
||||
|
||||
```python
|
||||
if idx is None:
|
||||
raise ValueError(
|
||||
f"[ItemizedCarrier] 无法为资源 '{resource.name}' 找到匹配的槽位。"
|
||||
f"已知槽位:{list(self.child_locations.keys())},"
|
||||
f"传入坐标:{location}"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 D — 跨站转运修复
|
||||
|
||||
#### D1. `YB_YH_materials.py` — 新增分液瓶板接驳槽
|
||||
|
||||
在 `YihuaCoinCellDeck.setup()` 中,新增一个专用于接收 Bioyond 侧传来的完整分液瓶板的 `ResourceStack`(或 `PlateSlot`):
|
||||
|
||||
```python
|
||||
# 在 setup() 末尾追加
|
||||
from pylabrobot.resources.resource_stack import ResourceStack
|
||||
|
||||
vial_plate_dock = ResourceStack(
|
||||
name="electrolyte_buffer", # 保持与 bioyond_cell_workstation.py 的 sites 键一致
|
||||
direction="z",
|
||||
resources=[],
|
||||
)
|
||||
self.assign_child_resource(vial_plate_dock, Coordinate(x=1050.0, y=700.0, z=0))
|
||||
```
|
||||
|
||||
> **说明**:槽位命名 `electrolyte_buffer` 与 `bioyond_cell_workstation.py` 现有的 `sites=["electrolyte_buffer"]` 对应,减少改动量。如改名,D2 需同步。
|
||||
|
||||
---
|
||||
|
||||
#### D2. `bioyond_cell_workstation.py` — 修正 transfer 目标
|
||||
|
||||
**问题**:第 1545-1552 行创建了一个 `size=1,1,1` 的虚拟 `ResourcePLR` 并硬编码 UUID,这个对象在 YihuaCoinCellDeck 的资源树中不存在,导致转移后资源树状态混乱。
|
||||
|
||||
**修改**:
|
||||
|
||||
```python
|
||||
# 修改前:创建虚拟目标资源
|
||||
target_resource_obj = ResourcePLR(name=target_location, size_x=1.0, ...)
|
||||
target_resource_obj.unilabos_uuid = "550e8400-e29b-41d4-a716-446655440001" # 硬编码
|
||||
|
||||
# 修改后:通过 ROS2/设备注册表查询真实资源
|
||||
# (需要从 target_device 的资源树中取出 electrolyte_buffer 的真实对象)
|
||||
target_resource_obj = self._get_resource_from_device(
|
||||
device_id=target_device,
|
||||
resource_name=target_location
|
||||
)
|
||||
if target_resource_obj is None:
|
||||
raise RuntimeError(
|
||||
f"目标设备 {target_device} 中未找到资源 '{target_location}',"
|
||||
f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位"
|
||||
)
|
||||
```
|
||||
|
||||
> **说明**:`_get_resource_from_device` 需根据现有 ROS2 资源同步机制实现,或复用已有的 `get_plr_resource_by_name` 类似方法。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 E — 配置与注册表
|
||||
|
||||
#### E1. `yibin_electrolyte_config.json` / `yibin_coin_cell_only_config.json` / `yibin_electrolyte_only_config.json`
|
||||
|
||||
全局替换以下字符串:
|
||||
|
||||
| 旧值 | 新值 |
|
||||
|---|---|
|
||||
| `BIOYOND_YB_Deck` | `BioyondElectrolyteDeck` |
|
||||
| `unilabos.resources.bioyond.decks:BIOYOND_YB_Deck` | `unilabos.resources.bioyond.decks:BioyondElectrolyteDeck` |
|
||||
| `CoincellDeck` | `YihuaCoinCellDeck` |
|
||||
| `unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck` | 若文件已重命名:`unilabos.devices.workstation.coin_cell_assembly.yihua_coin_cell_materials:YihuaCoinCellDeck` |
|
||||
|
||||
---
|
||||
|
||||
#### E2. `unilabos/registry/resources/bioyond/deck.yaml`(**原计划未覆盖,新增**)
|
||||
|
||||
当前第 25 行和第 37 行仍使用旧类名,需同步更新:
|
||||
|
||||
```yaml
|
||||
# 修改前
|
||||
BIOYOND_YB_Deck:
|
||||
...
|
||||
CoincellDeck:
|
||||
...
|
||||
|
||||
# 修改后
|
||||
BioyondElectrolyteDeck:
|
||||
...
|
||||
YihuaCoinCellDeck:
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 F — 物料余量监控集成(原计划第5节细化)
|
||||
|
||||
**目标**:弃用资源树内极片对象计数,改为直读依华扣电工站寄存器。
|
||||
|
||||
#### F1. `coin_cell_assembly/coin_cell_assembly.py` — 新增寄存器读取方法
|
||||
|
||||
参考 `coin_cell_assembly_b.csv` 中的地址,封装读取工具方法:
|
||||
|
||||
```python
|
||||
MATERIAL_REGISTER_MAP = {
|
||||
"10mm正极片": (520, "REAL"),
|
||||
"12mm正极片": (522, "REAL"),
|
||||
"16mm正极片": (524, "REAL"),
|
||||
"铝箔": (526, "REAL"),
|
||||
"正极壳": (528, "REAL"),
|
||||
"平垫": (530, "REAL"),
|
||||
"负极壳": (532, "REAL"),
|
||||
"弹垫": (534, "REAL"),
|
||||
"成品容量": (536, "REAL"),
|
||||
"成品NG容量": (538, "REAL"),
|
||||
}
|
||||
|
||||
def get_material_remaining(self, material_name: str) -> float:
|
||||
"""通过寄存器直读指定物料的剩余数量"""
|
||||
if material_name not in MATERIAL_REGISTER_MAP:
|
||||
raise KeyError(f"未知物料名称: {material_name}")
|
||||
address, dtype = MATERIAL_REGISTER_MAP[material_name]
|
||||
return self.read_hold_register(address, dtype) # 复用现有 Modbus 读取方法
|
||||
```
|
||||
|
||||
#### F2. 前端 data view 集成
|
||||
|
||||
- 前端点击 `MagazineHolder` 类资源的 data view 时,调用后端 `get_material_remaining` 接口(而非读取 `children` 长度)。
|
||||
- 具体接口路径和前端调用代码需与前端开发同步,本文档不作具体实现约定。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证计划(细化)
|
||||
|
||||
### 4.1 单元测试(自动化)
|
||||
|
||||
```bash
|
||||
# 序列化/反序列化往返测试
|
||||
python -m pytest unilabos/test/ -k "serial" -v
|
||||
|
||||
# 特别检查以下错误消失:
|
||||
# - ValueError: Resource '...' already assigned to deck
|
||||
# - KeyError: 'liquid_history'
|
||||
# - 重复 UUID 报错
|
||||
```
|
||||
|
||||
### 4.2 集成测试(手动)
|
||||
|
||||
按以下顺序逐步验证,确保每步正常后再进行下一步:
|
||||
|
||||
1. **单独启动 `BatteryStation` 节点**,检查 `CoincellDeck`(现 `YihuaCoinCellDeck`)能否从数据库状态正确还原,无 `already assigned` 报错。
|
||||
2. **单独启动 `BioyondElectrolyte` 节点**,检查 `BioyondElectrolyteDeck` 反序列化正常。
|
||||
3. **同时启动两个节点**,模拟执行一次分液→扣电的完整跨站转运,确认:
|
||||
- `electrolyte_buffer` 槽位正确接收分液瓶板。
|
||||
- `bottle_rack_6x2` 初始为空,不出现虚拟瓶子。
|
||||
4. **重启两个节点**(模拟断电恢复),确认资源树从数据库还原后,`electrolyte_buffer` 中仍持有正确的分液瓶板对象。
|
||||
5. **寄存器余量读取**:手动触发 `get_material_remaining("负极壳")`,确认返回值与设备显示一致。
|
||||
|
||||
---
|
||||
|
||||
## 5. 与原计划的差异对照
|
||||
|
||||
| 维度 | 原计划 | 本文档新增/修订 |
|
||||
|---|---|---|
|
||||
| 执行顺序 | 未排序 | 明确 A→B→C→D→E→F 的依赖顺序 |
|
||||
| `itemized_carrier.py` | 移除兜底补丁 | 补充:替换为带诊断信息的异常,便于排查 |
|
||||
| `bottle_carriers.py` | 提及 `YIHUA_Electrolyte_12VialCarrier` 需修改 | 明确:删除第 54-55 行的瓶子填充循环 |
|
||||
| `MaterialPlate` | 提及移除 `fill` 参数 | 说明保留 `fill=False` 路径;整体删除 `fill=True` 分支 |
|
||||
| `deck.yaml` | 未提及 | **新增**:该注册文件也需要同步更新类名 |
|
||||
| `resource_tracker.py` | 简略描述 | 提供具体的 `setdefault` 预处理代码示例 |
|
||||
| 跨站转运 | 描述了问题和方向 | 细化:新增 `electrolyte_buffer` 槽位的具体名称和坐标;修正 `transfer` 目标查找方式 |
|
||||
| 验证计划 | 简述目标 | 提供具体测试命令和逐步手动验证流程 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -336,6 +336,47 @@ separator.chinwe:
|
||||
title: pump_valve参数
|
||||
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:
|
||||
feedback: {}
|
||||
goal:
|
||||
|
||||
@@ -64,12 +64,59 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: fun_wuliao_test参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: /Users/sml/work
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
type: integer
|
||||
elec_num:
|
||||
type: string
|
||||
elec_use_num:
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
type: integer
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
type: string
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd_simp:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -102,7 +149,7 @@ coincellassemblyworkstation_device:
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 3200
|
||||
default: 4200
|
||||
description: 电池压制力(N)
|
||||
type: integer
|
||||
assembly_type:
|
||||
@@ -118,7 +165,7 @@ coincellassemblyworkstation_device:
|
||||
description: 是否启用压力模式
|
||||
type: boolean
|
||||
dual_drop_first_volume:
|
||||
default: 0
|
||||
default: 25
|
||||
description: 二次滴液第一次排液体积(μL)
|
||||
type: integer
|
||||
dual_drop_mode:
|
||||
@@ -137,7 +184,6 @@ coincellassemblyworkstation_device:
|
||||
description: 电解液瓶数
|
||||
type: string
|
||||
elec_use_num:
|
||||
default: 5
|
||||
description: 每瓶电解液组装电池数
|
||||
type: string
|
||||
elec_vol:
|
||||
@@ -145,7 +191,7 @@ coincellassemblyworkstation_device:
|
||||
description: 电解液吸液量(μL)
|
||||
type: integer
|
||||
file_path:
|
||||
default: D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly
|
||||
default: /Users/sml/work
|
||||
description: 实验记录保存路径
|
||||
type: string
|
||||
fujipian_juzhendianwei:
|
||||
@@ -176,7 +222,8 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd_simp参数
|
||||
@@ -265,7 +312,8 @@ coincellassemblyworkstation_device:
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init_auto_start_combined参数
|
||||
@@ -307,7 +355,8 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_stop参数
|
||||
@@ -332,7 +381,8 @@ coincellassemblyworkstation_device:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_get_msg_cmd参数
|
||||
@@ -346,10 +396,12 @@ coincellassemblyworkstation_device:
|
||||
handles:
|
||||
input:
|
||||
- data_key: bottle_num
|
||||
data_source: handle
|
||||
data_source: workflow
|
||||
data_type: integer
|
||||
handler_key: bottle_count
|
||||
io_type: source
|
||||
label: 配液瓶数
|
||||
required: true
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
@@ -384,7 +436,8 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_finished_cmd参数
|
||||
@@ -421,7 +474,8 @@ coincellassemblyworkstation_device:
|
||||
- assembly_type
|
||||
- assembly_pressure
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_msg_cmd参数
|
||||
@@ -477,21 +531,12 @@ coincellassemblyworkstation_device:
|
||||
handles:
|
||||
input:
|
||||
- data_key: elec_num
|
||||
data_source: handle
|
||||
data_source: workflow
|
||||
data_type: integer
|
||||
handler_key: bottle_count
|
||||
io_type: source
|
||||
label: 配液瓶数
|
||||
- data_key: formulations
|
||||
data_source: handle
|
||||
data_type: array
|
||||
handler_key: formulations_input
|
||||
label: 配方信息列表
|
||||
output:
|
||||
- data_key: assembly_data
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: assembly_data_output
|
||||
label: 扣电组装数据列表
|
||||
required: true
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
@@ -574,7 +619,8 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_sendbottle_allpack_multi参数
|
||||
@@ -626,31 +672,6 @@ coincellassemblyworkstation_device:
|
||||
title: modify_deck_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: object
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-qiming_coin_cell_code:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -698,7 +719,8 @@ coincellassemblyworkstation_device:
|
||||
required:
|
||||
- fujipian_panshu
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: qiming_coin_cell_code参数
|
||||
@@ -706,10 +728,6 @@ coincellassemblyworkstation_device:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||
status_types:
|
||||
data_10mm_positive_plate_remaining: float
|
||||
data_12mm_positive_plate_remaining: float
|
||||
data_16mm_positive_plate_remaining: float
|
||||
data_aluminum_foil_remaining: float
|
||||
data_assembly_coin_cell_num: int
|
||||
data_assembly_pressure: int
|
||||
data_assembly_time: float
|
||||
@@ -717,22 +735,14 @@ coincellassemblyworkstation_device:
|
||||
data_axis_y_pos: float
|
||||
data_axis_z_pos: float
|
||||
data_coin_cell_code: str
|
||||
data_coin_type: int
|
||||
data_current_assembling_count: int
|
||||
data_current_completed_count: int
|
||||
data_coin_num: int
|
||||
data_electrolyte_code: str
|
||||
data_electrolyte_volume: int
|
||||
data_finished_battery_ng_remaining_capacity: float
|
||||
data_finished_battery_remaining_capacity: float
|
||||
data_flat_washer_remaining: float
|
||||
data_glove_box_o2_content: float
|
||||
data_glove_box_pressure: float
|
||||
data_glove_box_water_content: float
|
||||
data_negative_shell_remaining: float
|
||||
data_open_circuit_voltage: float
|
||||
data_pole_weight: float
|
||||
data_positive_shell_remaining: float
|
||||
data_spring_washer_remaining: float
|
||||
request_rec_msg_status: bool
|
||||
request_send_msg_status: bool
|
||||
sys_mode: str
|
||||
@@ -762,14 +772,6 @@ coincellassemblyworkstation_device:
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
data_10mm_positive_plate_remaining:
|
||||
type: number
|
||||
data_12mm_positive_plate_remaining:
|
||||
type: number
|
||||
data_16mm_positive_plate_remaining:
|
||||
type: number
|
||||
data_aluminum_foil_remaining:
|
||||
type: number
|
||||
data_assembly_coin_cell_num:
|
||||
type: integer
|
||||
data_assembly_pressure:
|
||||
@@ -784,38 +786,22 @@ coincellassemblyworkstation_device:
|
||||
type: number
|
||||
data_coin_cell_code:
|
||||
type: string
|
||||
data_coin_type:
|
||||
type: integer
|
||||
data_current_assembling_count:
|
||||
type: integer
|
||||
data_current_completed_count:
|
||||
data_coin_num:
|
||||
type: integer
|
||||
data_electrolyte_code:
|
||||
type: string
|
||||
data_electrolyte_volume:
|
||||
type: integer
|
||||
data_finished_battery_ng_remaining_capacity:
|
||||
type: number
|
||||
data_finished_battery_remaining_capacity:
|
||||
type: number
|
||||
data_flat_washer_remaining:
|
||||
type: number
|
||||
data_glove_box_o2_content:
|
||||
type: number
|
||||
data_glove_box_pressure:
|
||||
type: number
|
||||
data_glove_box_water_content:
|
||||
type: number
|
||||
data_negative_shell_remaining:
|
||||
type: number
|
||||
data_open_circuit_voltage:
|
||||
type: number
|
||||
data_pole_weight:
|
||||
type: number
|
||||
data_positive_shell_remaining:
|
||||
type: number
|
||||
data_spring_washer_remaining:
|
||||
type: number
|
||||
request_rec_msg_status:
|
||||
type: boolean
|
||||
request_send_msg_status:
|
||||
@@ -825,36 +811,24 @@ coincellassemblyworkstation_device:
|
||||
sys_status:
|
||||
type: string
|
||||
required:
|
||||
- sys_status
|
||||
- sys_mode
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- data_assembly_coin_cell_num
|
||||
- data_open_circuit_voltage
|
||||
- data_assembly_pressure
|
||||
- data_assembly_time
|
||||
- data_axis_x_pos
|
||||
- data_axis_y_pos
|
||||
- data_axis_z_pos
|
||||
- data_pole_weight
|
||||
- data_assembly_pressure
|
||||
- data_electrolyte_volume
|
||||
- data_coin_type
|
||||
- data_current_assembling_count
|
||||
- data_current_completed_count
|
||||
- data_coin_cell_code
|
||||
- data_coin_num
|
||||
- data_electrolyte_code
|
||||
- data_glove_box_pressure
|
||||
- data_electrolyte_volume
|
||||
- data_glove_box_o2_content
|
||||
- data_glove_box_pressure
|
||||
- data_glove_box_water_content
|
||||
- data_10mm_positive_plate_remaining
|
||||
- data_12mm_positive_plate_remaining
|
||||
- data_16mm_positive_plate_remaining
|
||||
- data_aluminum_foil_remaining
|
||||
- data_positive_shell_remaining
|
||||
- data_flat_washer_remaining
|
||||
- data_negative_shell_remaining
|
||||
- data_spring_washer_remaining
|
||||
- data_finished_battery_remaining_capacity
|
||||
- data_finished_battery_ng_remaining_capacity
|
||||
- data_open_circuit_voltage
|
||||
- data_pole_weight
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- sys_mode
|
||||
- sys_status
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -6973,7 +6973,7 @@ liquid_handler.laiyu:
|
||||
properties:
|
||||
channel_num:
|
||||
default: 1
|
||||
type: string
|
||||
type: integer
|
||||
deck:
|
||||
type: object
|
||||
host:
|
||||
@@ -6984,10 +6984,25 @@ liquid_handler.laiyu:
|
||||
type: integer
|
||||
simulator:
|
||||
default: true
|
||||
type: string
|
||||
type: boolean
|
||||
timeout:
|
||||
default: 10.0
|
||||
type: number
|
||||
serial_port:
|
||||
default: /dev/ttyUSB0
|
||||
description: 硬件串口端口(非 simulator 模式下使用)
|
||||
type: string
|
||||
baudrate:
|
||||
default: 115200
|
||||
type: integer
|
||||
pipette_address:
|
||||
default: 4
|
||||
description: SOPA 移液器 RS485 地址
|
||||
type: integer
|
||||
total_height:
|
||||
default: 310
|
||||
description: 龙门架总高度 (mm),用于坐标转换
|
||||
type: number
|
||||
required:
|
||||
- deck
|
||||
type: object
|
||||
|
||||
286
unilabos/registry/devices/motor.yaml
Normal file
286
unilabos/registry/devices/motor.yaml
Normal file
@@ -0,0 +1,286 @@
|
||||
motor.zdt_x42:
|
||||
category:
|
||||
- motor
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-enable:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
'on': true
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 使能或禁用电机。使能后电机进入锁轴状态,可接收运动指令;禁用后电机进入松轴状态。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
'on':
|
||||
default: true
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-get_position:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 获取当前电机脉冲位置。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
position:
|
||||
type: integer
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: get_position参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_position:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
absolute: false
|
||||
acceleration: 10
|
||||
direction: CW
|
||||
pulses: 1000
|
||||
speed_rpm: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 位置模式运行。控制电机移动到指定脉冲位置或相对于当前位置移动指定脉冲数。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
absolute:
|
||||
default: false
|
||||
type: boolean
|
||||
acceleration:
|
||||
default: 10
|
||||
maximum: 255
|
||||
minimum: 0
|
||||
type: integer
|
||||
direction:
|
||||
default: CW
|
||||
enum:
|
||||
- CW
|
||||
- CCW
|
||||
type: string
|
||||
pulses:
|
||||
default: 1000
|
||||
type: integer
|
||||
speed_rpm:
|
||||
default: 60
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- pulses
|
||||
- speed_rpm
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_position参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_speed:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 10
|
||||
direction: CW
|
||||
speed_rpm: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 速度模式运行。控制电机以指定转速和方向持续转动。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 10
|
||||
maximum: 255
|
||||
minimum: 0
|
||||
type: integer
|
||||
direction:
|
||||
default: CW
|
||||
enum:
|
||||
- CW
|
||||
- CCW
|
||||
type: string
|
||||
speed_rpm:
|
||||
default: 60
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- speed_rpm
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_speed参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-rotate_quarter:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
direction: CW
|
||||
speed_rpm: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 电机旋转 1/4 圈 (阻塞式)。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
direction:
|
||||
default: CW
|
||||
enum:
|
||||
- CW
|
||||
- CCW
|
||||
type: string
|
||||
speed_rpm:
|
||||
default: 60
|
||||
minimum: 1
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: rotate_quarter参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_zero:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 将当前电机位置设为零点。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_zero参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-stop:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 立即停止电机运动。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_time:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
duration_s: 1.0
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 等待指定时间 (秒)。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
duration_s:
|
||||
default: 1.0
|
||||
minimum: 0
|
||||
type: number
|
||||
required:
|
||||
- duration_s
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_time参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.motor.ZDT_X42:ZDTX42Driver
|
||||
status_types:
|
||||
position: int
|
||||
status: str
|
||||
type: python
|
||||
config_info: []
|
||||
description: ZDT X42 闭环步进电机驱动。支持速度运行、精确位置控制、位置查询和清零功能。适用于各种需要精确运动控制的实验室自动化场景。
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
baudrate:
|
||||
default: 115200
|
||||
type: integer
|
||||
debug:
|
||||
default: false
|
||||
type: boolean
|
||||
device_id:
|
||||
default: 1
|
||||
type: integer
|
||||
port:
|
||||
type: string
|
||||
timeout:
|
||||
default: 0.5
|
||||
type: number
|
||||
required:
|
||||
- port
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
position:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- position
|
||||
type: object
|
||||
version: 1.0.0
|
||||
File diff suppressed because it is too large
Load Diff
148
unilabos/registry/devices/sensor.yaml
Normal file
148
unilabos/registry/devices/sensor.yaml
Normal file
@@ -0,0 +1,148 @@
|
||||
sensor.xkc_rs485:
|
||||
category:
|
||||
- sensor
|
||||
- separator
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-change_baudrate:
|
||||
goal:
|
||||
baud_code: 7
|
||||
handles: {}
|
||||
schema:
|
||||
description: '更改通讯波特率 (设置成功后无返回,且需手动切换波特率重连)。代码表 (16进制): 05=2400, 06=4800,
|
||||
07=9600, 08=14400, 09=19200, 0A=28800, 0C=57600, 0D=115200, 0E=128000,
|
||||
0F=256000'
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
baud_code:
|
||||
description: '波特率代码 (例如: 7 为 9600, 13 即 0x0D 为 115200)'
|
||||
type: integer
|
||||
required:
|
||||
- baud_code
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-change_device_id:
|
||||
goal:
|
||||
new_id: 1
|
||||
handles: {}
|
||||
schema:
|
||||
description: 修改传感器的 Modbus 从站地址
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
new_id:
|
||||
description: 新的从站地址 (1-254)
|
||||
maximum: 254
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- new_id
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-factory_reset:
|
||||
goal: {}
|
||||
handles: {}
|
||||
schema:
|
||||
description: 恢复出厂设置 (地址重置为 01)
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-read_level:
|
||||
goal: {}
|
||||
handles: {}
|
||||
schema:
|
||||
description: 直接读取当前液位及信号强度
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_threshold:
|
||||
goal:
|
||||
threshold: 300
|
||||
handles: {}
|
||||
schema:
|
||||
description: 设置液位判定阈值
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
threshold:
|
||||
type: integer
|
||||
required:
|
||||
- threshold
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_liquid:
|
||||
goal:
|
||||
target_state: true
|
||||
timeout: 120
|
||||
handles: {}
|
||||
schema:
|
||||
description: 实时检测电导率(RSSI)并等待用户指定的状态
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
target_state:
|
||||
default: true
|
||||
description: 目标状态 (True=有液, False=无液)
|
||||
type: boolean
|
||||
timeout:
|
||||
default: 120
|
||||
description: 超时时间 (秒)
|
||||
required:
|
||||
- target_state
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_level:
|
||||
goal:
|
||||
level: true
|
||||
timeout: 10
|
||||
handles: {}
|
||||
schema:
|
||||
description: 等待液位达到目标状态
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
level:
|
||||
type: boolean
|
||||
timeout:
|
||||
type: number
|
||||
required:
|
||||
- level
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.separator.xkc_sensor:XKCSensorDriver
|
||||
status_types:
|
||||
level: bool
|
||||
rssi: int
|
||||
type: python
|
||||
config_info: []
|
||||
description: XKC RS485 非接触式液位传感器 (Modbus RTU)
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
baudrate:
|
||||
default: 9600
|
||||
type: integer
|
||||
debug:
|
||||
default: false
|
||||
type: boolean
|
||||
device_id:
|
||||
default: 1
|
||||
type: integer
|
||||
port:
|
||||
type: string
|
||||
threshold:
|
||||
default: 300
|
||||
type: integer
|
||||
timeout:
|
||||
default: 3.0
|
||||
type: number
|
||||
required:
|
||||
- port
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -3960,6 +3960,14 @@ virtual_separator:
|
||||
io_type: source
|
||||
label: bottom_phase_out
|
||||
side: SOUTH
|
||||
- data_key: top_outlet
|
||||
data_source: executor
|
||||
data_type: fluid
|
||||
description: 上相(轻相)液体输出口
|
||||
handler_key: topphaseout
|
||||
io_type: source
|
||||
label: top_phase_out
|
||||
side: NORTH
|
||||
- data_key: mechanical_port
|
||||
data_source: handle
|
||||
data_type: mechanical
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
YIHUA_Electrolyte_12VialCarrier:
|
||||
category:
|
||||
- battery_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.battery.bottle_carriers:YIHUA_Electrolyte_12VialCarrier
|
||||
type: pylabrobot
|
||||
description: YIHUA 12-vial electrolyte carrier for coin cell assembly workstation
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
@@ -1,140 +1,84 @@
|
||||
YB_Vial_20mL:
|
||||
YB_20ml_fenyeping:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_Vial_20mL
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_20ml_fenyeping
|
||||
type: pylabrobot
|
||||
description: YB_Vial_20mL
|
||||
description: YB_20ml_fenyeping
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
YB_Vial_5mL:
|
||||
YB_5ml_fenyeping:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_Vial_5mL
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping
|
||||
type: pylabrobot
|
||||
description: YB_Vial_5mL
|
||||
description: YB_5ml_fenyeping
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
YB_DosingHead_L:
|
||||
YB_jia_yang_tou_da:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_DosingHead_L
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da
|
||||
type: pylabrobot
|
||||
description: YB_DosingHead_L
|
||||
description: YB_jia_yang_tou_da
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
YB_PrepBottle_60mL:
|
||||
YB_pei_ye_da_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_60mL
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_da_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_PrepBottle_60mL
|
||||
description: YB_pei_ye_da_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
YB_PrepBottle_15mL:
|
||||
YB_pei_ye_xiao_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_15mL
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_PrepBottle_15mL
|
||||
description: YB_pei_ye_xiao_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
YB_Tip_5000uL:
|
||||
YB_qiang_tou:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_5000uL
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_qiang_tou
|
||||
type: pylabrobot
|
||||
description: YB_Tip_5000uL
|
||||
description: YB_qiang_tou
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
YB_Tip_1000uL:
|
||||
category:
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_1000uL
|
||||
type: pylabrobot
|
||||
description: YB_Tip_1000uL
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_Tip_50uL:
|
||||
category:
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_50uL
|
||||
type: pylabrobot
|
||||
description: YB_Tip_50uL
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_NormalLiq_250mL_Bottle:
|
||||
YB_ye_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_250mL_Bottle
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_NormalLiq_250mL_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_NormalLiq_100mL_Bottle:
|
||||
category:
|
||||
- YB_bottle_carriers
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_100mL_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_NormalLiq_100mL_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_HighVis_250mL_Bottle:
|
||||
category:
|
||||
- YB_bottle_carriers
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_250mL_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_HighVis_250mL_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_HighVis_100mL_Bottle:
|
||||
category:
|
||||
- YB_bottle_carriers
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_100mL_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_HighVis_100mL_Bottle
|
||||
description: YB_ye_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
YB_Vial_20mL_Carrier:
|
||||
YB_100ml_yeti:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_20mL_Carrier
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_100ml_yeti
|
||||
type: pylabrobot
|
||||
description: YB_Vial_20mL_Carrier
|
||||
description: YB_100ml_yeti
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_Vial_5mL_Carrier:
|
||||
YB_20ml_fenyepingban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_5mL_Carrier
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_20ml_fenyepingban
|
||||
type: pylabrobot
|
||||
description: YB_Vial_5mL_Carrier
|
||||
description: YB_20ml_fenyepingban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
YB_5ml_fenyepingban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban
|
||||
type: pylabrobot
|
||||
description: YB_5ml_fenyepingban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_6StockCarrier:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier
|
||||
@@ -32,10 +45,10 @@ YB_6StockCarrier:
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_6VialCarrier:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier
|
||||
@@ -44,137 +57,112 @@ YB_6VialCarrier:
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_DosingHead_L_Carrier:
|
||||
YB_gao_nian_ye_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_DosingHead_L_Carrier
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_gao_nian_ye_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_DosingHead_L_Carrier
|
||||
description: YB_gao_nian_ye_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_PrepBottle_60mL_Carrier:
|
||||
YB_gaonianye:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_60mL_Carrier
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_gaonianye
|
||||
type: pylabrobot
|
||||
description: YB_PrepBottle_60mL_Carrier
|
||||
description: YB_gaonianye
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_PrepBottle_15mL_Carrier:
|
||||
YB_jia_yang_tou_da_Carrier:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_15mL_Carrier
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_Carrier
|
||||
type: pylabrobot
|
||||
description: YB_PrepBottle_15mL_Carrier
|
||||
description: YB_jia_yang_tou_da_Carrier
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_TipRack_Mixed:
|
||||
YB_peiyepingdaban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_Mixed
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingdaban
|
||||
type: pylabrobot
|
||||
description: YB_TipRack_Mixed
|
||||
description: YB_peiyepingdaban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_TipRack_5000uL:
|
||||
YB_peiyepingxiaoban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_5000uL
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban
|
||||
type: pylabrobot
|
||||
description: YB_TipRack_5000uL
|
||||
description: YB_peiyepingxiaoban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_TipRack_50uL:
|
||||
YB_qiang_tou_he:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_50uL
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_qiang_tou_he
|
||||
type: pylabrobot
|
||||
description: YB_TipRack_50uL
|
||||
description: YB_qiang_tou_he
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_Adapter_60mL:
|
||||
YB_shi_pei_qi_kuai:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Adapter_60mL
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_shi_pei_qi_kuai
|
||||
type: pylabrobot
|
||||
description: YB_Adapter_60mL
|
||||
description: YB_shi_pei_qi_kuai
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_NormalLiq_250mL_Carrier:
|
||||
YB_ye:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_250mL_Carrier
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_ye
|
||||
type: pylabrobot
|
||||
description: YB_NormalLiq_250mL_Carrier
|
||||
description: YB_ye_Bottle_Carrier
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_NormalLiq_100mL_Carrier:
|
||||
YB_ye_100ml_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_100mL_Carrier
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_ye_100ml_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_NormalLiq_100mL_Carrier
|
||||
description: YB_ye_100ml_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_HighVis_250mL_Carrier:
|
||||
category:
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_250mL_Carrier
|
||||
type: pylabrobot
|
||||
description: YB_HighVis_250mL_Carrier
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_HighVis_100mL_Carrier:
|
||||
category:
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_100mL_Carrier
|
||||
type: pylabrobot
|
||||
description: YB_HighVis_100mL_Carrier
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
@@ -20,22 +20,22 @@ BIOYOND_PolymerReactionStation_Deck:
|
||||
icon: 反应站.webp
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
BioyondElectrolyteDeck:
|
||||
BIOYOND_YB_Deck:
|
||||
category:
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.resources.bioyond.decks:bioyond_electrolyte_deck
|
||||
module: unilabos.resources.bioyond.decks:YB_Deck
|
||||
type: pylabrobot
|
||||
description: BIOYOND ElectrolyteFormulationStation Deck
|
||||
handles: []
|
||||
icon: 配液站.webp
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
YihuaCoinCellDeck:
|
||||
CoincellDeck:
|
||||
category:
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:yihua_coin_cell_deck
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:YH_Deck
|
||||
type: pylabrobot
|
||||
description: YIHUA CoinCellAssembly Deck
|
||||
handles: []
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||
from unilabos.resources.bioyond.YB_bottles import (
|
||||
YB_pei_ye_xiao_Bottle,
|
||||
)
|
||||
# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
|
||||
|
||||
@@ -48,5 +51,6 @@ def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier:
|
||||
carrier.num_items_x = 2
|
||||
carrier.num_items_y = 6
|
||||
carrier.num_items_z = 1
|
||||
# 载架初始化为空,瓶子由实际转运操作填入,避免反序列化时重复 assign
|
||||
for i in range(12):
|
||||
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
|
||||
return carrier
|
||||
|
||||
@@ -135,7 +135,6 @@ class BatteryState(TypedDict):
|
||||
open_circuit_voltage: float
|
||||
assembly_pressure: float
|
||||
electrolyte_volume: float
|
||||
pole_weight: float # 极片称重 (mg)
|
||||
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
@@ -180,7 +179,6 @@ class Battery(Container):
|
||||
open_circuit_voltage=0.0,
|
||||
assembly_pressure=0.0,
|
||||
electrolyte_volume=0.0,
|
||||
pole_weight=0.0,
|
||||
info=None
|
||||
)
|
||||
|
||||
|
||||
@@ -53,28 +53,13 @@ class Magazine(ResourceStack):
|
||||
return self.get_size_z()
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
# 物料余量由寄存器接管,不再持久化极片子节点,
|
||||
# 防止旧数据写回数据库后下次启动时再次引发重复 UUID。
|
||||
data["children"] = []
|
||||
data.update({
|
||||
return {
|
||||
**super().serialize(),
|
||||
"size_x": self.size_x or 10.0,
|
||||
"size_y": self.size_y or 10.0,
|
||||
"size_z": self.size_z or 10.0,
|
||||
"max_sheets": self.max_sheets,
|
||||
})
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal: bool = False):
|
||||
"""反序列化时丢弃极片子节点(ElectrodeSheet 等)。
|
||||
|
||||
物料余量已由寄存器接管,不再在资源树中追踪每个极片实体。
|
||||
清空 children 可防止数据库中的旧极片记录被重新加载,避免重复 UUID 报错。
|
||||
"""
|
||||
data = dict(data)
|
||||
data["children"] = []
|
||||
return super().deserialize(data, allow_marshal=allow_marshal)
|
||||
}
|
||||
|
||||
|
||||
class MagazineHolder(ItemizedResource):
|
||||
@@ -235,7 +220,7 @@ def MagazineHolder_6_Cathode(
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
locations=locations,
|
||||
klasses=None,
|
||||
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
|
||||
hole_diameter=hole_diameter,
|
||||
hole_depth=hole_depth,
|
||||
max_sheets_per_hole=max_sheets_per_hole,
|
||||
@@ -273,7 +258,7 @@ def MagazineHolder_6_Anode(
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
locations=locations,
|
||||
klasses=None,
|
||||
klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan],
|
||||
hole_diameter=hole_diameter,
|
||||
hole_depth=hole_depth,
|
||||
max_sheets_per_hole=max_sheets_per_hole,
|
||||
@@ -350,7 +335,7 @@ def MagazineHolder_4_Cathode(
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
locations=locations,
|
||||
klasses=None,
|
||||
klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode],
|
||||
hole_diameter=hole_diameter,
|
||||
hole_depth=hole_depth,
|
||||
max_sheets_per_hole=max_sheets_per_hole,
|
||||
|
||||
@@ -2,18 +2,15 @@ from pylabrobot.resources import create_homogeneous_resources, Coordinate, Resou
|
||||
|
||||
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||
from unilabos.resources.bioyond.YB_bottles import (
|
||||
YB_DosingHead_L,
|
||||
YB_NormalLiq_250mL_Bottle,
|
||||
YB_NormalLiq_100mL_Bottle,
|
||||
YB_HighVis_250mL_Bottle,
|
||||
YB_HighVis_100mL_Bottle,
|
||||
YB_Vial_5mL,
|
||||
YB_Vial_20mL,
|
||||
YB_PrepBottle_15mL,
|
||||
YB_PrepBottle_60mL,
|
||||
YB_Tip_5000uL,
|
||||
YB_Tip_1000uL,
|
||||
YB_Tip_50uL,
|
||||
YB_jia_yang_tou_da,
|
||||
YB_ye_Bottle,
|
||||
YB_ye_100ml_Bottle,
|
||||
YB_gao_nian_ye_Bottle,
|
||||
YB_5ml_fenyeping,
|
||||
YB_20ml_fenyeping,
|
||||
YB_pei_ye_xiao_Bottle,
|
||||
YB_pei_ye_da_Bottle,
|
||||
YB_qiang_tou,
|
||||
)
|
||||
# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
|
||||
@@ -209,7 +206,7 @@ def YB_6VialCarrier(name: str) -> BottleCarrier:
|
||||
return carrier
|
||||
|
||||
# 1瓶载架 - 单个中央位置
|
||||
def YB_NormalLiq_250mL_Carrier(name: str) -> BottleCarrier:
|
||||
def YB_ye(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
@@ -236,17 +233,17 @@ def YB_NormalLiq_250mL_Carrier(name: str) -> BottleCarrier:
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_NormalLiq_250mL_Carrier",
|
||||
model="YB_ye",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_NormalLiq_250mL_Bottle(f"{name}_flask_1")
|
||||
carrier[0] = YB_ye_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
|
||||
|
||||
# 高粘液瓶载架 - 单个中央位置
|
||||
def YB_HighVis_250mL_Carrier(name: str) -> BottleCarrier:
|
||||
def YB_gaonianye(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
@@ -273,17 +270,17 @@ def YB_HighVis_250mL_Carrier(name: str) -> BottleCarrier:
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_HighVis_250mL_Carrier",
|
||||
model="YB_gaonianye",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_HighVis_250mL_Bottle(f"{name}_flask_1")
|
||||
carrier[0] = YB_gao_nian_ye_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
|
||||
|
||||
# 100mL普通液瓶载架 - 单个中央位置
|
||||
def YB_NormalLiq_100mL_Carrier(name: str) -> BottleCarrier:
|
||||
# 100ml液体瓶载架 - 单个中央位置
|
||||
def YB_100ml_yeti(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
@@ -310,52 +307,16 @@ def YB_NormalLiq_100mL_Carrier(name: str) -> BottleCarrier:
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_NormalLiq_100mL_Carrier",
|
||||
model="YB_100ml_yeti",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_NormalLiq_100mL_Bottle(f"{name}_flask_1")
|
||||
carrier[0] = YB_ye_100ml_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
|
||||
# 100mL高粘液瓶载架 - 单个中央位置
|
||||
def YB_HighVis_100mL_Carrier(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 20.0
|
||||
|
||||
# 烧杯尺寸
|
||||
beaker_diameter = 60.0
|
||||
|
||||
# 计算中央位置
|
||||
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||
center_z = 5.0
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(center_x, center_y, center_z)],
|
||||
resource_size_x=beaker_diameter,
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_HighVis_100mL_Carrier",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_HighVis_100mL_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
|
||||
# 5mL分液瓶板 - 4x2布局,8个位置
|
||||
def YB_Vial_5mL_Carrier(name: str) -> BottleCarrier:
|
||||
# 5ml分液瓶板 - 4x2布局,8个位置
|
||||
def YB_5ml_fenyepingban(name: str) -> BottleCarrier:
|
||||
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
@@ -394,18 +355,18 @@ def YB_Vial_5mL_Carrier(name: str) -> BottleCarrier:
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_Vial_5mL_Carrier",
|
||||
model="YB_5ml_fenyepingban",
|
||||
)
|
||||
carrier.num_items_x = 4
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||
for i in range(8):
|
||||
carrier[i] = YB_Vial_5mL(f"{name}_vial_{ordering[i]}")
|
||||
carrier[i] = YB_5ml_fenyeping(f"{name}_vial_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
# 20mL分液瓶板 - 4x2布局,8个位置
|
||||
def YB_Vial_20mL_Carrier(name: str) -> BottleCarrier:
|
||||
# 20ml分液瓶板 - 4x2布局,8个位置
|
||||
def YB_20ml_fenyepingban(name: str) -> BottleCarrier:
|
||||
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
@@ -444,18 +405,18 @@ def YB_Vial_20mL_Carrier(name: str) -> BottleCarrier:
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_Vial_20mL_Carrier",
|
||||
model="YB_20ml_fenyepingban",
|
||||
)
|
||||
carrier.num_items_x = 4
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||
for i in range(8):
|
||||
carrier[i] = YB_Vial_20mL(f"{name}_vial_{ordering[i]}")
|
||||
carrier[i] = YB_20ml_fenyeping(f"{name}_vial_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
# 配液瓶(小)板 - 4x2布局,8个位置
|
||||
def YB_PrepBottle_15mL_Carrier(name: str) -> BottleCarrier:
|
||||
def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
|
||||
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
@@ -494,19 +455,19 @@ def YB_PrepBottle_15mL_Carrier(name: str) -> BottleCarrier:
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_PrepBottle_15mL_Carrier",
|
||||
model="YB_peiyepingxiaoban",
|
||||
)
|
||||
carrier.num_items_x = 4
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||
for i in range(8):
|
||||
carrier[i] = YB_PrepBottle_15mL(f"{name}_bottle_{ordering[i]}")
|
||||
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
|
||||
# 配液瓶(大)板 - 2x2布局,4个位置
|
||||
def YB_PrepBottle_60mL_Carrier(name: str) -> BottleCarrier:
|
||||
def YB_peiyepingdaban(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
@@ -544,18 +505,18 @@ def YB_PrepBottle_60mL_Carrier(name: str) -> BottleCarrier:
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_PrepBottle_60mL_Carrier",
|
||||
model="YB_peiyepingdaban",
|
||||
)
|
||||
carrier.num_items_x = 2
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "B1", "B2"]
|
||||
for i in range(4):
|
||||
carrier[i] = YB_PrepBottle_60mL(f"{name}_bottle_{ordering[i]}")
|
||||
carrier[i] = YB_pei_ye_da_Bottle(f"{name}_bottle_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
# 加样头(大)板 - 1x1布局,1个位置
|
||||
def YB_DosingHead_L_Carrier(name: str) -> BottleCarrier:
|
||||
def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
@@ -593,16 +554,16 @@ def YB_DosingHead_L_Carrier(name: str) -> BottleCarrier:
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_DosingHead_L_Carrier",
|
||||
model="YB_jia_yang_tou_da_Carrier",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_DosingHead_L(f"{name}_head_1")
|
||||
carrier[0] = YB_jia_yang_tou_da(f"{name}_head_1")
|
||||
return carrier
|
||||
|
||||
|
||||
def YB_Adapter_60mL(name: str) -> BottleCarrier:
|
||||
def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
|
||||
"""适配器块 - 单个中央位置"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
@@ -630,7 +591,7 @@ def YB_Adapter_60mL(name: str) -> BottleCarrier:
|
||||
resource_size_y=adapter_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_Adapter_60mL",
|
||||
model="YB_shi_pei_qi_kuai",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
@@ -639,7 +600,7 @@ def YB_Adapter_60mL(name: str) -> BottleCarrier:
|
||||
return carrier
|
||||
|
||||
|
||||
def YB_TipRack_50uL(name: str) -> BottleCarrier:
|
||||
def YB_qiang_tou_he(name: str) -> BottleCarrier:
|
||||
"""枪头盒 - 8x12布局,96个位置"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
@@ -648,9 +609,9 @@ def YB_TipRack_50uL(name: str) -> BottleCarrier:
|
||||
carrier_size_z = 55.0
|
||||
|
||||
# 枪头尺寸
|
||||
tip_diameter = 7.0
|
||||
tip_spacing_x = 7.5 # X方向间距
|
||||
tip_spacing_y = 7.5 # Y方向间距
|
||||
tip_diameter = 10.0
|
||||
tip_spacing_x = 9.0 # X方向间距
|
||||
tip_spacing_y = 9.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (12 - 1) * tip_spacing_x - tip_diameter) / 2
|
||||
@@ -678,7 +639,7 @@ def YB_TipRack_50uL(name: str) -> BottleCarrier:
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_TipRack_50uL",
|
||||
model="YB_qiang_tou_he",
|
||||
)
|
||||
carrier.num_items_x = 12
|
||||
carrier.num_items_y = 8
|
||||
@@ -687,182 +648,6 @@ def YB_TipRack_50uL(name: str) -> BottleCarrier:
|
||||
for i in range(96):
|
||||
row = chr(65 + i // 12) # A-H
|
||||
col = (i % 12) + 1 # 1-12
|
||||
carrier[i] = YB_Tip_50uL(f"{name}_tip_{row}{col}")
|
||||
return carrier
|
||||
|
||||
|
||||
def YB_TipRack_5000uL(name: str) -> BottleCarrier:
|
||||
"""枪头盒 - 4x6布局,24个位置"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 95.0
|
||||
|
||||
# 枪头尺寸
|
||||
tip_diameter = 16.0
|
||||
tip_spacing_x = 16.5 # X方向间距
|
||||
tip_spacing_y = 16.5 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (6 - 1) * tip_spacing_x - tip_diameter) / 2
|
||||
start_y = (carrier_size_y - (4 - 1) * tip_spacing_y - tip_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=6,
|
||||
num_items_y=4,
|
||||
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=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_TipRack_5000uL",
|
||||
)
|
||||
carrier.num_items_x = 6
|
||||
carrier.num_items_y = 4
|
||||
carrier.num_items_z = 1
|
||||
# 创建24个枪头
|
||||
for i in range(24):
|
||||
row = chr(65 + i // 6) # A-D
|
||||
col = (i % 6) + 1 # 1-6
|
||||
carrier[i] = YB_Tip_5000uL(f"{name}_tip_{row}{col}")
|
||||
return carrier
|
||||
|
||||
|
||||
|
||||
def YB_TipRack_Mixed(name: str) -> BottleCarrier:
|
||||
"""混合枪头盒 - 复杂布局
|
||||
上层: 2x8空位(原50uL枪头位置,现空余)
|
||||
中层: 4x4布局,放5000uL枪头
|
||||
下层: 2x8布局,放1000uL枪头
|
||||
"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 95.0
|
||||
|
||||
# 各类枪头的尺寸参数
|
||||
tip_5000_diameter = 16.0
|
||||
tip_5000_spacing_x = 16.5
|
||||
tip_5000_spacing_y = 16.5
|
||||
|
||||
tip_1000_diameter = 7.0
|
||||
tip_1000_spacing_x = 7.5
|
||||
tip_1000_spacing_y = 7.5
|
||||
|
||||
# 空位尺寸(上层2x8,原50uL位置)
|
||||
empty_diameter = 7.0
|
||||
empty_spacing_x = 7.5
|
||||
empty_spacing_y = 7.5
|
||||
|
||||
# 计算各层的起始位置
|
||||
# 上层空位 (2x8)
|
||||
empty_top_start_x = (carrier_size_x - (8 - 1) * empty_spacing_x - empty_diameter) / 2
|
||||
empty_top_start_y = 5.0
|
||||
|
||||
# 中层5000uL (4x4)
|
||||
tip_5000_start_x = (carrier_size_x - (4 - 1) * tip_5000_spacing_x - tip_5000_diameter) / 2
|
||||
tip_5000_start_y = empty_top_start_y + 2 * empty_spacing_y + 5.0
|
||||
|
||||
# 下层1000uL (2x8)
|
||||
tip_1000_start_x = (carrier_size_x - (8 - 1) * tip_1000_spacing_x - tip_1000_diameter) / 2
|
||||
tip_1000_start_y = tip_5000_start_y + 4 * tip_5000_spacing_y + 5.0
|
||||
|
||||
sites = {}
|
||||
|
||||
# 创建上层空位 (2x8) - 不创建实际的枪头对象
|
||||
empty_top_sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=8,
|
||||
num_items_y=2,
|
||||
dx=empty_top_start_x,
|
||||
dy=empty_top_start_y,
|
||||
dz=5.0,
|
||||
item_dx=empty_spacing_x,
|
||||
item_dy=empty_spacing_y,
|
||||
size_x=empty_diameter,
|
||||
size_y=empty_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
# 添加空位,索引 0-15
|
||||
for k, v in empty_top_sites.items():
|
||||
v.name = f"{name}_empty_top_{v.name}"
|
||||
sites[k] = v
|
||||
|
||||
# 创建中层5000uL枪头位 (4x4),索引 16-31
|
||||
tip_5000_sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=tip_5000_start_x,
|
||||
dy=tip_5000_start_y,
|
||||
dz=15.0,
|
||||
item_dx=tip_5000_spacing_x,
|
||||
item_dy=tip_5000_spacing_y,
|
||||
size_x=tip_5000_diameter,
|
||||
size_y=tip_5000_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for i, (k, v) in enumerate(tip_5000_sites.items()):
|
||||
v.name = f"{name}_5000_{v.name}"
|
||||
sites[16 + i] = v
|
||||
|
||||
# 创建下层1000uL枪头位 (2x8),索引 32-47
|
||||
tip_1000_sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=8,
|
||||
num_items_y=2,
|
||||
dx=tip_1000_start_x,
|
||||
dy=tip_1000_start_y,
|
||||
dz=25.0,
|
||||
item_dx=tip_1000_spacing_x,
|
||||
item_dy=tip_1000_spacing_y,
|
||||
size_x=tip_1000_diameter,
|
||||
size_y=tip_1000_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for i, (k, v) in enumerate(tip_1000_sites.items()):
|
||||
v.name = f"{name}_1000_{v.name}"
|
||||
sites[32 + i] = v
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_TipRack_Mixed",
|
||||
)
|
||||
carrier.num_items_x = 8 # 最大宽度
|
||||
carrier.num_items_y = 8 # 总行数 (2+4+2)
|
||||
carrier.num_items_z = 1
|
||||
|
||||
# 为5000uL枪头创建实例 (16个),对应索引 16-31
|
||||
for i in range(16):
|
||||
row = chr(65 + i // 4) # A-D
|
||||
col = (i % 4) + 1 # 1-4
|
||||
carrier[16 + i] = YB_Tip_5000uL(f"{name}_tip5000_{row}{col}")
|
||||
|
||||
# 为1000uL枪头创建实例 (16个),对应索引 32-47
|
||||
for i in range(16):
|
||||
row = chr(65 + i // 8) # A-B
|
||||
col = (i % 8) + 1 # 1-8
|
||||
carrier[32 + i] = YB_Tip_1000uL(f"{name}_tip1000_{row}{col}")
|
||||
|
||||
carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}")
|
||||
return carrier
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||
# 工厂函数
|
||||
"""加样头(大)"""
|
||||
def YB_DosingHead_L(
|
||||
def YB_jia_yang_tou_da(
|
||||
name: str,
|
||||
diameter: float = 20.0,
|
||||
height: float = 100.0,
|
||||
@@ -15,11 +15,11 @@ def YB_DosingHead_L(
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_DosingHead_L",
|
||||
model="YB_jia_yang_tou_da",
|
||||
)
|
||||
|
||||
"""250mL普通液"""
|
||||
def YB_NormalLiq_250mL_Bottle(
|
||||
"""液1x1"""
|
||||
def YB_ye_Bottle(
|
||||
name: str,
|
||||
diameter: float = 40.0,
|
||||
height: float = 70.0,
|
||||
@@ -33,105 +33,87 @@ def YB_NormalLiq_250mL_Bottle(
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_NormalLiq_250mL_Bottle",
|
||||
model="YB_ye_Bottle",
|
||||
)
|
||||
|
||||
"""100mL普通液"""
|
||||
def YB_NormalLiq_100mL_Bottle(
|
||||
"""100ml液体"""
|
||||
def YB_ye_100ml_Bottle(
|
||||
name: str,
|
||||
diameter: float = 50.0,
|
||||
height: float = 90.0,
|
||||
max_volume: float = 100000.0, # 100mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建100mL普通液瓶"""
|
||||
"""创建100ml液体瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_NormalLiq_100mL_Bottle",
|
||||
model="YB_100ml_yeti",
|
||||
)
|
||||
|
||||
"""100mL高粘液"""
|
||||
def YB_HighVis_100mL_Bottle(
|
||||
name: str,
|
||||
diameter: float = 50.0,
|
||||
height: float = 90.0,
|
||||
max_volume: float = 100000.0, # 100mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建100mL高粘液瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_HighVis_100mL_Bottle",
|
||||
)
|
||||
|
||||
"""250mL高粘液"""
|
||||
def YB_HighVis_250mL_Bottle(
|
||||
"""高粘液"""
|
||||
def YB_gao_nian_ye_Bottle(
|
||||
name: str,
|
||||
diameter: float = 40.0,
|
||||
height: float = 70.0,
|
||||
max_volume: float = 50000.0, # 50mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建250mL高粘液瓶"""
|
||||
"""创建高粘液瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_HighVis_250mL_Bottle",
|
||||
model="High_Viscosity_Liquid",
|
||||
)
|
||||
|
||||
"""5mL分液瓶"""
|
||||
def YB_Vial_5mL(
|
||||
"""5ml分液瓶"""
|
||||
def YB_5ml_fenyeping(
|
||||
name: str,
|
||||
diameter: float = 20.0,
|
||||
height: float = 50.0,
|
||||
max_volume: float = 5000.0, # 5mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建5mL分液瓶"""
|
||||
"""创建5ml分液瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_Vial_5mL",
|
||||
model="YB_5ml_fenyeping",
|
||||
)
|
||||
|
||||
"""20mL分液瓶"""
|
||||
def YB_Vial_20mL(
|
||||
"""20ml分液瓶"""
|
||||
def YB_20ml_fenyeping(
|
||||
name: str,
|
||||
diameter: float = 30.0,
|
||||
height: float = 65.0,
|
||||
max_volume: float = 20000.0, # 20mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建20mL分液瓶"""
|
||||
"""创建20ml分液瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_Vial_20mL",
|
||||
model="YB_20ml_fenyeping",
|
||||
)
|
||||
|
||||
"""配液瓶(小)"""
|
||||
def YB_PrepBottle_15mL(
|
||||
def YB_pei_ye_xiao_Bottle(
|
||||
name: str,
|
||||
diameter: float = 35.0,
|
||||
height: float = 60.0,
|
||||
max_volume: float = 15000.0, # 15mL
|
||||
max_volume: float = 30000.0, # 30mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建配液瓶(小)"""
|
||||
@@ -141,15 +123,15 @@ def YB_PrepBottle_15mL(
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_PrepBottle_15mL",
|
||||
model="YB_pei_ye_xiao_Bottle",
|
||||
)
|
||||
|
||||
"""配液瓶(大)"""
|
||||
def YB_PrepBottle_60mL(
|
||||
def YB_pei_ye_da_Bottle(
|
||||
name: str,
|
||||
diameter: float = 55.0,
|
||||
height: float = 100.0,
|
||||
max_volume: float = 60000.0, # 60mL
|
||||
max_volume: float = 150000.0, # 150mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建配液瓶(大)"""
|
||||
@@ -159,29 +141,11 @@ def YB_PrepBottle_60mL(
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_PrepBottle_60mL",
|
||||
model="YB_pei_ye_da_Bottle",
|
||||
)
|
||||
|
||||
"""5000uL枪头"""
|
||||
def YB_Tip_5000uL(
|
||||
name: str,
|
||||
diameter: float = 10.0,
|
||||
height: float = 50.0,
|
||||
max_volume: float = 5000.0, # 5mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建枪头"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_Tip_5000uL",
|
||||
)
|
||||
|
||||
"""1000uL枪头"""
|
||||
def YB_Tip_1000uL(
|
||||
"""枪头"""
|
||||
def YB_qiang_tou(
|
||||
name: str,
|
||||
diameter: float = 10.0,
|
||||
height: float = 50.0,
|
||||
@@ -195,23 +159,5 @@ def YB_Tip_1000uL(
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_Tip_1000uL",
|
||||
model="YB_qiang_tou",
|
||||
)
|
||||
|
||||
"""50uL枪头"""
|
||||
def YB_Tip_50uL(
|
||||
name: str,
|
||||
diameter: float = 10.0,
|
||||
height: float = 50.0,
|
||||
max_volume: float = 50.0, # 50uL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建枪头"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_Tip_50uL",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from os import name
|
||||
from pylabrobot.resources import Deck, Coordinate, Rotation
|
||||
|
||||
from unilabos.resources.bioyond.YB_warehouses import (
|
||||
@@ -33,8 +34,11 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
|
||||
size_y: float = 1080.0,
|
||||
size_z: float = 1500.0,
|
||||
category: str = "deck",
|
||||
setup: bool = False
|
||||
) -> None:
|
||||
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
# 添加仓库
|
||||
@@ -62,7 +66,6 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
|
||||
class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -71,8 +74,11 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||
size_y: float = 1080.0,
|
||||
size_z: float = 1500.0,
|
||||
category: str = "deck",
|
||||
setup: bool = False
|
||||
) -> None:
|
||||
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
# 添加仓库 - 配液站的3个堆栈,使用Bioyond系统中的实际名称
|
||||
@@ -95,8 +101,7 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
|
||||
class BioyondElectrolyteDeck(Deck):
|
||||
class BIOYOND_YB_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "YB_Deck",
|
||||
@@ -104,7 +109,7 @@ class BioyondElectrolyteDeck(Deck):
|
||||
size_y: float = 1400.0,
|
||||
size_z: float = 2670.0,
|
||||
category: str = "deck",
|
||||
setup: bool = False,
|
||||
setup: bool = False
|
||||
) -> None:
|
||||
super().__init__(name=name, size_x=4150.0, size_y=1400.0, size_z=2670.0)
|
||||
if setup:
|
||||
@@ -113,8 +118,8 @@ class BioyondElectrolyteDeck(Deck):
|
||||
def setup(self) -> None:
|
||||
# 添加仓库
|
||||
self.warehouses = {
|
||||
"自动堆栈-左": bioyond_warehouse_2x2x1("自动堆栈-左"), # 2行×2列
|
||||
"自动堆栈-右": bioyond_warehouse_2x2x1("自动堆栈-右"), # 2行×2列
|
||||
"321窗口": bioyond_warehouse_2x2x1("321窗口"), # 2行×2列
|
||||
"43窗口": bioyond_warehouse_2x2x1("43窗口"), # 2行×2列
|
||||
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03
|
||||
"手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03
|
||||
"加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"),
|
||||
@@ -128,34 +133,29 @@ class BioyondElectrolyteDeck(Deck):
|
||||
}
|
||||
# warehouse 的位置
|
||||
self.warehouse_locations = {
|
||||
"自动堆栈-左": Coordinate(-150.0, 1142.0, 0.0),
|
||||
"自动堆栈-右": Coordinate(4160.0, 1142.0, 0.0),
|
||||
"手动传递窗左": Coordinate(-150.0, 423.0, 0.0),
|
||||
"手动传递窗右": Coordinate(4160.0, 423.0, 0.0),
|
||||
"加样头堆栈左": Coordinate(385.0, 0, 0.0),
|
||||
"加样头堆栈右": Coordinate(2187.0, 0, 0.0),
|
||||
"321窗口": Coordinate(-150.0, 158.0, 0.0),
|
||||
"43窗口": Coordinate(4160.0, 158.0, 0.0),
|
||||
"手动传递窗左": Coordinate(-150.0, 877.0, 0.0),
|
||||
"手动传递窗右": Coordinate(4160.0, 877.0, 0.0),
|
||||
"加样头堆栈左": Coordinate(385.0, 1300.0, 0.0),
|
||||
"加样头堆栈右": Coordinate(2187.0, 1300.0, 0.0),
|
||||
|
||||
"15ml配液堆栈左": Coordinate(749.0, 945.0, 0.0),
|
||||
"母液加样右": Coordinate(2152.0, 967.0, 0.0),
|
||||
"大瓶母液堆栈左": Coordinate(1164.0, 624.0, 0.0),
|
||||
"大瓶母液堆栈右": Coordinate(2717.0, 624.0, 0.0),
|
||||
"2号手套箱内部堆栈": Coordinate(-800, 800.0, 0.0), # 新增:位置需根据实际硬件调整
|
||||
"15ml配液堆栈左": Coordinate(749.0, 355.0, 0.0),
|
||||
"母液加样右": Coordinate(2152.0, 333.0, 0.0),
|
||||
"大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0),
|
||||
"大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0),
|
||||
"2号手套箱内部堆栈": Coordinate(-800, -500.0, 0.0), # 新增:位置需根据实际硬件调整
|
||||
}
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
|
||||
# 向后兼容别名,日后废弃
|
||||
BIOYOND_YB_Deck = BioyondElectrolyteDeck
|
||||
def YB_Deck(name: str) -> Deck:
|
||||
by=BIOYOND_YB_Deck(name=name)
|
||||
by.setup()
|
||||
return by
|
||||
|
||||
|
||||
|
||||
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
|
||||
deck = BioyondElectrolyteDeck(name=name)
|
||||
deck.setup()
|
||||
return deck
|
||||
|
||||
|
||||
# 向后兼容别名,日后废弃
|
||||
def YB_Deck(name: str) -> BioyondElectrolyteDeck:
|
||||
return bioyond_electrolyte_deck(name)
|
||||
|
||||
@@ -797,7 +797,9 @@ 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
|
||||
)
|
||||
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
|
||||
# 只有具有 tracker 属性的容器才设置液体信息(如 Bottle, Well)
|
||||
# ResourceHolder 等不支持液体追踪的容器跳过
|
||||
if hasattr(bottle, "tracker"):
|
||||
bottle.tracker.liquids = [
|
||||
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
|
||||
]
|
||||
@@ -809,7 +811,8 @@ 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
|
||||
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
|
||||
# 确保 bottle 有 tracker 属性才设置液体信息
|
||||
if hasattr(bottle, "tracker"):
|
||||
bottle.tracker.liquids = [
|
||||
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
|
||||
]
|
||||
@@ -841,24 +844,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列
|
||||
|
||||
@@ -866,15 +874,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 ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||
|
||||
@@ -179,35 +179,11 @@ class ItemizedCarrier(ResourcePLR):
|
||||
idx = i
|
||||
break
|
||||
|
||||
if idx is None and location is not None:
|
||||
# 精确坐标匹配失败(常见原因:DB 存储的 z=0,而槽位定义 z=dz>0)。
|
||||
# 降级为仅按 XY 坐标进行近似匹配,找到后使用槽位自身的正确坐标写回,
|
||||
# 避免因 Z 偏移导致反序列化中断。
|
||||
_XY_TOLERANCE = 2.0 # mm,覆盖浮点误差和 z 偏移
|
||||
min_dist = float("inf")
|
||||
nearest_idx = None
|
||||
for _i, _loc in enumerate(self.child_locations.values()):
|
||||
_d = (((_loc.x - location.x) ** 2) + ((_loc.y - location.y) ** 2)) ** 0.5
|
||||
if _d < min_dist:
|
||||
min_dist = _d
|
||||
nearest_idx = _i
|
||||
if nearest_idx is not None and min_dist <= _XY_TOLERANCE:
|
||||
from unilabos.utils.log import logger as _logger
|
||||
_slot_label = list(self.child_locations.keys())[nearest_idx]
|
||||
_logger.warning(
|
||||
f"[ItemizedCarrier '{self.name}'] 资源 '{resource.name}' 坐标 {location} 与槽位 "
|
||||
f"'{_slot_label}' {list(self.child_locations.values())[nearest_idx]} 的 XY 吻合"
|
||||
f"(XY 偏差={min_dist:.2f}mm),按 XY 近似匹配成功,z 偏移已被修正。"
|
||||
)
|
||||
idx = nearest_idx
|
||||
|
||||
if idx is None:
|
||||
raise ValueError(
|
||||
f"[ItemizedCarrier '{self.name}'] 无法为资源 '{resource.name}' 找到匹配的槽位。\n"
|
||||
f" 已知槽位: {list(self.child_locations.keys())}\n"
|
||||
f" 传入坐标: {location}\n"
|
||||
f" 提示: XY 近似匹配也失败,请检查资源坐标或 Carrier 槽位定义是否正确。"
|
||||
)
|
||||
# 反序列化时无法匹配 site(名称或坐标均不符)。
|
||||
# WareHouse 通过 sites 追踪占用,无需将子资源加入 PLR 子树,直接跳过避免命名冲突。
|
||||
return
|
||||
|
||||
if not reassign and self.sites[idx] is not None:
|
||||
raise ValueError(f"a site with index {idx} already exists")
|
||||
location = list(self.child_locations.values())[idx]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -423,6 +423,7 @@ class ResourceTreeSet(object):
|
||||
"deck": "deck",
|
||||
"tip_rack": "tip_rack",
|
||||
"tip_spot": "tip_spot",
|
||||
"tip": "tip", # 添加 tip 类型支持
|
||||
"tube": "tube",
|
||||
"bottle_carrier": "bottle_carrier",
|
||||
"material_hole": "material_hole",
|
||||
@@ -605,38 +606,21 @@ class ResourceTreeSet(object):
|
||||
},
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
|
||||
"category": res.config.get("category", plr_type),
|
||||
"children": [node_to_plr_dict(child, has_model) for child in node.children],
|
||||
# WareHouse 通过 sites 字符串追踪占位,不依赖 PLR children tree。
|
||||
# 将 WareHouse 子节点排除在外,避免同名载架出现在多个 WareHouse 下时
|
||||
# PLR _check_naming_conflicts 报命名冲突。
|
||||
"children": [] if res.type == "warehouse" else [node_to_plr_dict(child, has_model) for child in node.children],
|
||||
"parent_name": res.parent_instance_name,
|
||||
}
|
||||
if has_model:
|
||||
d["model"] = res.config.get("model", None)
|
||||
# 仅当 PLR dict 中含有子节点时才禁用 setup(),
|
||||
# 防止 setup() 预分配子资源后 PLR deserialize 再次分配同名资源产生命名冲突。
|
||||
# 若 children 为空,则保留 setup=True,依赖 setup() 来初始化仓库。
|
||||
if "setup" in d and d.get("children"):
|
||||
d["setup"] = False
|
||||
return d
|
||||
|
||||
def _deduplicate_plr_dict(d: dict, _seen: set = None) -> dict:
|
||||
"""递归去除 children 中同名重复节点(全树范围、保留首次出现)。
|
||||
|
||||
根本原因:同一槽位被 sync_from_external(Bioyond 同步)重复写入,
|
||||
导致数据库中同一 WareHouse 下存在多条同名 BottleCarrier 记录(不同 UUID)。
|
||||
PLR 的 _check_naming_conflicts 在全树范围检查名称唯一性,
|
||||
重复名称会在 deserialize 时抛出 ValueError,导致节点启动失败。
|
||||
此函数在 sub_cls.deserialize 前预先清理,保证名称唯一。
|
||||
"""
|
||||
if _seen is None:
|
||||
_seen = set()
|
||||
children = d.get("children", [])
|
||||
deduped = []
|
||||
for child in children:
|
||||
child = _deduplicate_plr_dict(child, _seen)
|
||||
cname = child.get("name")
|
||||
if cname not in _seen:
|
||||
_seen.add(cname)
|
||||
deduped.append(child)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[资源树去重] 发现重复资源名称 '{cname}',跳过重复项(历史脏数据)"
|
||||
)
|
||||
return {**d, "children": deduped}
|
||||
|
||||
plr_resources = []
|
||||
tracker = DeviceNodeResourceTracker()
|
||||
|
||||
@@ -647,8 +631,6 @@ class ResourceTreeSet(object):
|
||||
collect_node_data(tree.root_node, name_to_uuid, all_states, name_to_extra)
|
||||
has_model = tree.root_node.res_content.type != "deck"
|
||||
plr_dict = node_to_plr_dict(tree.root_node, has_model)
|
||||
plr_dict = _deduplicate_plr_dict(plr_dict)
|
||||
|
||||
try:
|
||||
sub_cls = find_subclass(plr_dict["type"], PLRResource)
|
||||
if skip_devices and plr_dict["type"] == "device":
|
||||
@@ -667,14 +649,6 @@ class ResourceTreeSet(object):
|
||||
|
||||
location = cast(Coordinate, deserialize(plr_dict["location"]))
|
||||
plr_resource.location = location
|
||||
|
||||
# 预填 Container 类型资源在新版 PLR 中要求必须存在的键,
|
||||
# 防止旧数据库状态缺失这些键时 load_all_state 抛出 KeyError。
|
||||
for state in all_states.values():
|
||||
if isinstance(state, dict):
|
||||
state.setdefault("liquid_history", [])
|
||||
state.setdefault("pending_liquids", {})
|
||||
|
||||
plr_resource.load_all_state(all_states)
|
||||
# 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra
|
||||
tracker.loop_set_uuid(plr_resource, name_to_uuid)
|
||||
@@ -897,13 +871,34 @@ class ResourceTreeSet(object):
|
||||
f"已存在,跳过"
|
||||
)
|
||||
|
||||
# 移除本地有但远端已不存在的物料(以远端为准)
|
||||
remote_material_names = {m.res_content.name for m in remote_child.children}
|
||||
removed_count = 0
|
||||
for child in list(local_sub_device.children):
|
||||
if child.res_content.name not in remote_material_names:
|
||||
local_sub_device.children.remove(child)
|
||||
removed_count += 1
|
||||
logger.info(
|
||||
f"移除远端已不存在的物料: '{remote_root_id}/{remote_child_name}/{child.res_content.name}'"
|
||||
)
|
||||
|
||||
if added_count > 0:
|
||||
logger.info(
|
||||
f"Device '{remote_root_id}/{remote_child_name}': "
|
||||
f"从远端同步了 {added_count} 个物料子树"
|
||||
)
|
||||
if removed_count > 0:
|
||||
logger.info(
|
||||
f"Device '{remote_root_id}/{remote_child_name}': "
|
||||
f"移除了 {removed_count} 个远端已删除的物料"
|
||||
)
|
||||
else:
|
||||
# 二级物料已存在,比较三级子节点是否缺失
|
||||
if remote_child_name not in local_children_map:
|
||||
logger.warning(
|
||||
f"物料 '{remote_root_id}/{remote_child_name}' 在远端存在但本地不存在,跳过"
|
||||
)
|
||||
continue
|
||||
local_material = local_children_map[remote_child_name]
|
||||
local_material_children_map = {child.res_content.name: child for child in
|
||||
local_material.children}
|
||||
@@ -919,11 +914,28 @@ class ResourceTreeSet(object):
|
||||
f"物料 '{remote_root_id}/{remote_child_name}/{remote_sub_name}' "
|
||||
f"已存在,跳过"
|
||||
)
|
||||
|
||||
# 移除本地有但远端已不存在的子物料(以远端为准)
|
||||
remote_sub_names = {s.res_content.name for s in remote_child.children}
|
||||
removed_count = 0
|
||||
for child in list(local_material.children):
|
||||
if child.res_content.name not in remote_sub_names:
|
||||
local_material.children.remove(child)
|
||||
removed_count += 1
|
||||
logger.info(
|
||||
f"移除远端已不存在的子物料: '{remote_root_id}/{remote_child_name}/{child.res_content.name}'"
|
||||
)
|
||||
|
||||
if added_count > 0:
|
||||
logger.info(
|
||||
f"物料 '{remote_root_id}/{remote_child_name}': "
|
||||
f"从远端同步了 {added_count} 个子物料"
|
||||
)
|
||||
if removed_count > 0:
|
||||
logger.info(
|
||||
f"物料 '{remote_root_id}/{remote_child_name}': "
|
||||
f"移除了 {removed_count} 个远端已删除的子物料"
|
||||
)
|
||||
else:
|
||||
# 情况1: 一级节点是物料(不是 device)
|
||||
# 检查是否已存在
|
||||
@@ -1364,6 +1376,16 @@ class DeviceNodeResourceTracker(object):
|
||||
else:
|
||||
res_list.extend(self.loop_find_resource(r, type(query_resource), "unilabos_uuid", res_uuid))
|
||||
|
||||
# 同一资源对象可能通过"直接注册"和"作为父资源子节点"被搜索到两次,按对象 id 去重
|
||||
seen_ids: set = set()
|
||||
deduped = []
|
||||
for item in res_list:
|
||||
oid = id(item[1])
|
||||
if oid not in seen_ids:
|
||||
seen_ids.add(oid)
|
||||
deduped.append(item)
|
||||
res_list = deduped
|
||||
|
||||
if not try_mode:
|
||||
assert len(res_list) > 0, f"没有找到资源 (uuid={res_uuid}),请检查资源是否存在"
|
||||
assert len(res_list) == 1, f"通过uuid={res_uuid} 找到多个资源,请检查资源是否唯一: {res_list}"
|
||||
@@ -1400,6 +1422,14 @@ class DeviceNodeResourceTracker(object):
|
||||
r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key)
|
||||
)
|
||||
)
|
||||
seen_ids2: set = set()
|
||||
deduped2 = []
|
||||
for item in res_list:
|
||||
oid = id(item[1])
|
||||
if oid not in seen_ids2:
|
||||
seen_ids2.add(oid)
|
||||
deduped2.append(item)
|
||||
res_list = deduped2
|
||||
if not try_mode:
|
||||
assert len(res_list) > 0, f"没有找到资源 {query_resource},请检查资源是否存在"
|
||||
assert len(res_list) == 1, f"{query_resource} 找到多个资源,请检查资源是否唯一: {res_list}"
|
||||
|
||||
@@ -41,9 +41,8 @@ def warehouse_factory(
|
||||
|
||||
# 根据 layout 决定 y 坐标计算
|
||||
if layout == "row-major":
|
||||
# 行优先:row=0(A行) 应该显示在上方
|
||||
# 前端现在 y 越大越靠上,所以 row=0 对应最大的 y
|
||||
y = dy + (num_items_y - row - 1) * item_dy
|
||||
# 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值
|
||||
y = dy + row * item_dy
|
||||
elif layout == "vertical-col-major":
|
||||
# 竖向warehouse: row=0 对应顶部(y小),row=n-1 对应底部(y大)
|
||||
# 但标签 01 应该在底部,所以使用反向映射
|
||||
|
||||
@@ -2340,4 +2340,4 @@ class DeviceInfoType(TypedDict):
|
||||
status_publishers: Dict[str, PropertyPublisher]
|
||||
actions: Dict[str, ActionServer]
|
||||
hardware_interface: Dict[str, Any]
|
||||
base_node_instance: BaseROS2DeviceNode
|
||||
base_node_instance: BaseROS2DeviceNode
|
||||
|
||||
@@ -15,92 +15,92 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"api_key": "YOUR_API_KEY",
|
||||
"api_host": "http://your-api-host:port",
|
||||
"api_key": "<BIOYOND_API_KEY>",
|
||||
"api_host": "http://<BIOYOND_HOST>:<BIOYOND_PORT>",
|
||||
"material_type_mappings": {
|
||||
"BIOYOND_PolymerStation_1FlaskCarrier": [
|
||||
"烧杯",
|
||||
"uuid-placeholder-flask"
|
||||
"<UUID_FLASK_CARRIER_TYPE>"
|
||||
],
|
||||
"BIOYOND_PolymerStation_1BottleCarrier": [
|
||||
"试剂瓶",
|
||||
"uuid-placeholder-bottle"
|
||||
"<UUID_BOTTLE_CARRIER_TYPE>"
|
||||
],
|
||||
"BIOYOND_PolymerStation_6StockCarrier": [
|
||||
"分装板",
|
||||
"uuid-placeholder-stock-6"
|
||||
"<UUID_6STOCK_CARRIER_TYPE>"
|
||||
],
|
||||
"BIOYOND_PolymerStation_Liquid_Vial": [
|
||||
"10%分装小瓶",
|
||||
"uuid-placeholder-liquid-vial"
|
||||
"<UUID_LIQUID_VIAL_TYPE>"
|
||||
],
|
||||
"BIOYOND_PolymerStation_Solid_Vial": [
|
||||
"90%分装小瓶",
|
||||
"uuid-placeholder-solid-vial"
|
||||
"<UUID_SOLID_VIAL_TYPE>"
|
||||
],
|
||||
"BIOYOND_PolymerStation_8StockCarrier": [
|
||||
"样品板",
|
||||
"uuid-placeholder-stock-8"
|
||||
"<UUID_8STOCK_CARRIER_TYPE>"
|
||||
],
|
||||
"BIOYOND_PolymerStation_Solid_Stock": [
|
||||
"样品瓶",
|
||||
"uuid-placeholder-solid-stock"
|
||||
"<UUID_SOLID_STOCK_TYPE>"
|
||||
]
|
||||
},
|
||||
"warehouse_mapping": {
|
||||
"粉末堆栈": {
|
||||
"uuid": "uuid-placeholder-powder-stack",
|
||||
"uuid": "<UUID_POWDER_WAREHOUSE>",
|
||||
"site_uuids": {
|
||||
"A01": "uuid-placeholder-powder-A01",
|
||||
"A02": "uuid-placeholder-powder-A02",
|
||||
"A03": "uuid-placeholder-powder-A03",
|
||||
"A04": "uuid-placeholder-powder-A04",
|
||||
"B01": "uuid-placeholder-powder-B01",
|
||||
"B02": "uuid-placeholder-powder-B02",
|
||||
"B03": "uuid-placeholder-powder-B03",
|
||||
"B04": "uuid-placeholder-powder-B04",
|
||||
"C01": "uuid-placeholder-powder-C01",
|
||||
"C02": "uuid-placeholder-powder-C02",
|
||||
"C03": "uuid-placeholder-powder-C03",
|
||||
"C04": "uuid-placeholder-powder-C04",
|
||||
"D01": "uuid-placeholder-powder-D01",
|
||||
"D02": "uuid-placeholder-powder-D02",
|
||||
"D03": "uuid-placeholder-powder-D03",
|
||||
"D04": "uuid-placeholder-powder-D04"
|
||||
"A01": "<UUID_POWDER_A01>",
|
||||
"A02": "<UUID_POWDER_A02>",
|
||||
"A03": "<UUID_POWDER_A03>",
|
||||
"A04": "<UUID_POWDER_A04>",
|
||||
"B01": "<UUID_POWDER_B01>",
|
||||
"B02": "<UUID_POWDER_B02>",
|
||||
"B03": "<UUID_POWDER_B03>",
|
||||
"B04": "<UUID_POWDER_B04>",
|
||||
"C01": "<UUID_POWDER_C01>",
|
||||
"C02": "<UUID_POWDER_C02>",
|
||||
"C03": "<UUID_POWDER_C03>",
|
||||
"C04": "<UUID_POWDER_C04>",
|
||||
"D01": "<UUID_POWDER_D01>",
|
||||
"D02": "<UUID_POWDER_D02>",
|
||||
"D03": "<UUID_POWDER_D03>",
|
||||
"D04": "<UUID_POWDER_D04>"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "uuid-placeholder-liquid-stack",
|
||||
"uuid": "<UUID_SOLUTION_WAREHOUSE>",
|
||||
"site_uuids": {
|
||||
"A01": "uuid-placeholder-liquid-A01",
|
||||
"A02": "uuid-placeholder-liquid-A02",
|
||||
"A03": "uuid-placeholder-liquid-A03",
|
||||
"A04": "uuid-placeholder-liquid-A04",
|
||||
"B01": "uuid-placeholder-liquid-B01",
|
||||
"B02": "uuid-placeholder-liquid-B02",
|
||||
"B03": "uuid-placeholder-liquid-B03",
|
||||
"B04": "uuid-placeholder-liquid-B04",
|
||||
"C01": "uuid-placeholder-liquid-C01",
|
||||
"C02": "uuid-placeholder-liquid-C02",
|
||||
"C03": "uuid-placeholder-liquid-C03",
|
||||
"C04": "uuid-placeholder-liquid-C04",
|
||||
"D01": "uuid-placeholder-liquid-D01",
|
||||
"D02": "uuid-placeholder-liquid-D02",
|
||||
"D03": "uuid-placeholder-liquid-D03",
|
||||
"D04": "uuid-placeholder-liquid-D04"
|
||||
"A01": "<UUID_SOLUTION_A01>",
|
||||
"A02": "<UUID_SOLUTION_A02>",
|
||||
"A03": "<UUID_SOLUTION_A03>",
|
||||
"A04": "<UUID_SOLUTION_A04>",
|
||||
"B01": "<UUID_SOLUTION_B01>",
|
||||
"B02": "<UUID_SOLUTION_B02>",
|
||||
"B03": "<UUID_SOLUTION_B03>",
|
||||
"B04": "<UUID_SOLUTION_B04>",
|
||||
"C01": "<UUID_SOLUTION_C01>",
|
||||
"C02": "<UUID_SOLUTION_C02>",
|
||||
"C03": "<UUID_SOLUTION_C03>",
|
||||
"C04": "<UUID_SOLUTION_C04>",
|
||||
"D01": "<UUID_SOLUTION_D01>",
|
||||
"D02": "<UUID_SOLUTION_D02>",
|
||||
"D03": "<UUID_SOLUTION_D03>",
|
||||
"D04": "<UUID_SOLUTION_D04>"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "uuid-placeholder-reagent-stack",
|
||||
"uuid": "<UUID_REAGENT_WAREHOUSE>",
|
||||
"site_uuids": {
|
||||
"A01": "uuid-placeholder-reagent-A01",
|
||||
"A02": "uuid-placeholder-reagent-A02",
|
||||
"A03": "uuid-placeholder-reagent-A03",
|
||||
"A04": "uuid-placeholder-reagent-A04",
|
||||
"B01": "uuid-placeholder-reagent-B01",
|
||||
"B02": "uuid-placeholder-reagent-B02",
|
||||
"B03": "uuid-placeholder-reagent-B03",
|
||||
"B04": "uuid-placeholder-reagent-B04"
|
||||
"A01": "<UUID_REAGENT_A01>",
|
||||
"A02": "<UUID_REAGENT_A02>",
|
||||
"A03": "<UUID_REAGENT_A03>",
|
||||
"A04": "<UUID_REAGENT_A04>",
|
||||
"B01": "<UUID_REAGENT_B01>",
|
||||
"B02": "<UUID_REAGENT_B02>",
|
||||
"B03": "<UUID_REAGENT_B03>",
|
||||
"B04": "<UUID_REAGENT_B04>"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -156,4 +156,4 @@
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
29
unilabos/test/experiments/xkc_sensor_test.json
Normal file
29
unilabos/test/experiments/xkc_sensor_test.json
Normal 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": []
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "YB_Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BioyondElectrolyteDeck"
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
||||
}
|
||||
},
|
||||
"protocol_type": [],
|
||||
@@ -103,14 +103,15 @@
|
||||
"children": [],
|
||||
"parent": "bioyond_cell_workstation",
|
||||
"type": "deck",
|
||||
"class": "BioyondElectrolyteDeck",
|
||||
"class": "BIOYOND_YB_Deck",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "BioyondElectrolyteDeck",
|
||||
"type": "BIOYOND_YB_Deck",
|
||||
"setup": true,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
|
||||
28
unilabos/test/experiments/zdt_motor_test.json
Normal file
28
unilabos/test/experiments/zdt_motor_test.json
Normal 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": []
|
||||
}
|
||||
@@ -188,7 +188,13 @@ class EnvironmentChecker:
|
||||
"crcmod": "crcmod-plus",
|
||||
}
|
||||
|
||||
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"}
|
||||
# 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败
|
||||
pylabrobot_url = (
|
||||
"git+https://gitee.com/xuwznln/pylabrobot.git"
|
||||
if _is_chinese_locale()
|
||||
else "git+https://github.com/Xuwznln/pylabrobot.git"
|
||||
)
|
||||
self.special_packages = {"pylabrobot": pylabrobot_url}
|
||||
|
||||
self.version_requirements = {
|
||||
"msgcenterpy": "0.1.8",
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
from datetime import datetime
|
||||
import ctypes
|
||||
import atexit
|
||||
import inspect
|
||||
from typing import Tuple, cast
|
||||
|
||||
# 添加TRACE级别到logging模块
|
||||
TRACE_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LEVEL, "TRACE")
|
||||
|
||||
|
||||
class CustomRecord:
|
||||
custom_stack_info: Tuple[str, int, str, str]
|
||||
|
||||
|
||||
# Windows颜色支持
|
||||
if platform.system() == "Windows":
|
||||
# 尝试启用Windows终端的ANSI支持
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
# 获取STD_OUTPUT_HANDLE
|
||||
STD_OUTPUT_HANDLE = -11
|
||||
# 启用ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
||||
# 获取当前控制台模式
|
||||
handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
|
||||
mode = ctypes.c_ulong()
|
||||
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
||||
# 启用ANSI处理
|
||||
kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
|
||||
|
||||
# 程序退出时恢复控制台设置
|
||||
@atexit.register
|
||||
def reset_console():
|
||||
kernel32.SetConsoleMode(handle, mode.value)
|
||||
|
||||
|
||||
# 定义不同日志级别的颜色
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""自定义日志格式化器,支持颜色输出"""
|
||||
|
||||
# ANSI 颜色代码
|
||||
COLORS = {
|
||||
"RESET": "\033[0m", # 重置
|
||||
"BOLD": "\033[1m", # 加粗
|
||||
"GRAY": "\033[37m", # 灰色
|
||||
"WHITE": "\033[97m", # 白色
|
||||
"BLACK": "\033[30m", # 黑色
|
||||
"TRACE_LEVEL": "\033[1;90m", # 加粗深灰色
|
||||
"DEBUG_LEVEL": "\033[1;36m", # 加粗青色
|
||||
"INFO_LEVEL": "\033[1;32m", # 加粗绿色
|
||||
"WARNING_LEVEL": "\033[1;33m", # 加粗黄色
|
||||
"ERROR_LEVEL": "\033[1;31m", # 加粗红色
|
||||
"CRITICAL_LEVEL": "\033[1;35m", # 加粗紫色
|
||||
"TRACE_TEXT": "\033[90m", # 深灰色
|
||||
"DEBUG_TEXT": "\033[37m", # 灰色
|
||||
"INFO_TEXT": "\033[97m", # 白色
|
||||
"WARNING_TEXT": "\033[33m", # 黄色
|
||||
"ERROR_TEXT": "\033[31m", # 红色
|
||||
"CRITICAL_TEXT": "\033[35m", # 紫色
|
||||
"DATE": "\033[37m", # 日期始终使用灰色
|
||||
}
|
||||
|
||||
def __init__(self, use_colors=True):
|
||||
super().__init__()
|
||||
# 强制启用颜色
|
||||
self.use_colors = use_colors
|
||||
|
||||
def format(self, record):
|
||||
# 检查是否有自定义堆栈信息
|
||||
if hasattr(record, "custom_stack_info") and record.custom_stack_info: # type: ignore
|
||||
r = cast(CustomRecord, record)
|
||||
frame_info = r.custom_stack_info
|
||||
record.filename = frame_info[0]
|
||||
record.lineno = frame_info[1]
|
||||
record.funcName = frame_info[2]
|
||||
if len(frame_info) > 3:
|
||||
record.name = frame_info[3]
|
||||
if not self.use_colors:
|
||||
return self._format_basic(record)
|
||||
|
||||
level_color = self.COLORS.get(f"{record.levelname}_LEVEL", self.COLORS["WHITE"])
|
||||
text_color = self.COLORS.get(f"{record.levelname}_TEXT", self.COLORS["WHITE"])
|
||||
date_color = self.COLORS["DATE"]
|
||||
reset = self.COLORS["RESET"]
|
||||
|
||||
# 日期格式
|
||||
datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]"
|
||||
|
||||
# 模块和函数信息
|
||||
filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名)
|
||||
if "/" in filename:
|
||||
filename = filename.split("/")[-1]
|
||||
module_path = f"{record.name}.{filename}"
|
||||
func_line = f"{record.funcName}:{record.lineno}"
|
||||
right_info = f" [{func_line}] [{module_path}]"
|
||||
|
||||
# 主要消息
|
||||
main_msg = record.getMessage()
|
||||
|
||||
# 构建基本消息格式
|
||||
formatted_message = (
|
||||
f"{date_color}{datetime_str}{reset} "
|
||||
f"{level_color}[{record.levelname}]{reset} "
|
||||
f"{text_color}{main_msg}"
|
||||
f"{date_color}{right_info}{reset}"
|
||||
)
|
||||
|
||||
# 处理异常信息
|
||||
if record.exc_info:
|
||||
exc_text = self.formatException(record.exc_info)
|
||||
if formatted_message[-1:] != "\n":
|
||||
formatted_message = formatted_message + "\n"
|
||||
formatted_message = formatted_message + text_color + exc_text + reset
|
||||
elif record.stack_info:
|
||||
if formatted_message[-1:] != "\n":
|
||||
formatted_message = formatted_message + "\n"
|
||||
formatted_message = formatted_message + text_color + self.formatStack(record.stack_info) + reset
|
||||
|
||||
return formatted_message
|
||||
|
||||
def _format_basic(self, record):
|
||||
"""基本格式化,不包含颜色"""
|
||||
datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]"
|
||||
filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名)
|
||||
if "/" in filename:
|
||||
filename = filename.split("/")[-1]
|
||||
module_path = f"{record.name}.{filename}"
|
||||
func_line = f"{record.funcName}:{record.lineno}"
|
||||
right_info = f" [{func_line}] [{module_path}]"
|
||||
|
||||
formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}"
|
||||
|
||||
if record.exc_info:
|
||||
exc_text = self.formatException(record.exc_info)
|
||||
if formatted_message[-1:] != "\n":
|
||||
formatted_message = formatted_message + "\n"
|
||||
formatted_message = formatted_message + exc_text
|
||||
elif record.stack_info:
|
||||
if formatted_message[-1:] != "\n":
|
||||
formatted_message = formatted_message + "\n"
|
||||
formatted_message = formatted_message + self.formatStack(record.stack_info)
|
||||
|
||||
return formatted_message
|
||||
|
||||
def formatException(self, exc_info):
|
||||
"""重写异常格式化,确保异常信息保持正确的格式和颜色"""
|
||||
# 获取标准的异常格式化文本
|
||||
formatted_exc = super().formatException(exc_info)
|
||||
return formatted_exc
|
||||
|
||||
|
||||
# 配置日志处理器
|
||||
def configure_logger(loglevel=None, working_dir=None):
|
||||
"""配置日志记录器
|
||||
|
||||
Args:
|
||||
loglevel: 日志级别,可以是字符串('TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
|
||||
或logging模块的常量(如logging.DEBUG)或TRACE_LEVEL
|
||||
"""
|
||||
# 获取根日志记录器
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(TRACE_LEVEL)
|
||||
# 设置日志级别
|
||||
numeric_level = logging.DEBUG
|
||||
if loglevel is not None:
|
||||
if isinstance(loglevel, str):
|
||||
# 将字符串转换为logging级别
|
||||
if loglevel.upper() == "TRACE":
|
||||
numeric_level = TRACE_LEVEL
|
||||
else:
|
||||
numeric_level = getattr(logging, loglevel.upper(), None)
|
||||
if not isinstance(numeric_level, int):
|
||||
print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG")
|
||||
else:
|
||||
numeric_level = loglevel
|
||||
|
||||
# 移除已存在的处理器
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# 创建控制台处理器
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(numeric_level) # 使用与根记录器相同的级别
|
||||
|
||||
# 使用自定义的颜色格式化器
|
||||
color_formatter = ColoredFormatter()
|
||||
console_handler.setFormatter(color_formatter)
|
||||
|
||||
# 添加处理器到根日志记录器
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 如果指定了工作目录,添加文件处理器
|
||||
if working_dir is not None:
|
||||
logs_dir = os.path.join(working_dir, "logs")
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
|
||||
# 生成日志文件名:日期 时间.log
|
||||
log_filename = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log"
|
||||
log_filepath = os.path.join(logs_dir, log_filename)
|
||||
|
||||
# 创建文件处理器
|
||||
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
|
||||
file_handler.setLevel(TRACE_LEVEL)
|
||||
|
||||
# 使用不带颜色的格式化器
|
||||
file_formatter = ColoredFormatter(use_colors=False)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
logging.getLogger("asyncio").setLevel(logging.INFO)
|
||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||
|
||||
|
||||
|
||||
# 配置日志系统
|
||||
configure_logger()
|
||||
|
||||
# 获取日志记录器
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 获取调用栈信息的工具函数
|
||||
def _get_caller_info(stack_level=0) -> Tuple[str, int, str, str]:
|
||||
"""
|
||||
获取调用者的信息
|
||||
|
||||
Args:
|
||||
stack_level: 堆栈回溯的级别,0表示当前函数,1表示调用者,依此类推
|
||||
|
||||
Returns:
|
||||
(filename, line_number, function_name, module_name) 元组
|
||||
"""
|
||||
# 堆栈级别需要加3:
|
||||
# +1 因为这个函数本身占一层
|
||||
# +1 因为日志函数(debug, info等)占一层
|
||||
# +1 因为下面调用 inspect.stack() 也占一层
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
# 跳过适当的堆栈帧
|
||||
for _ in range(stack_level + 3):
|
||||
if frame and frame.f_back:
|
||||
frame = frame.f_back
|
||||
else:
|
||||
break
|
||||
|
||||
if frame:
|
||||
filename = frame.f_code.co_filename if frame.f_code else "unknown"
|
||||
line_number = frame.f_lineno if hasattr(frame, "f_lineno") else 0
|
||||
function_name = frame.f_code.co_name if frame.f_code else "unknown"
|
||||
|
||||
# 获取模块名称
|
||||
module_name = "unknown"
|
||||
if frame.f_globals and "__name__" in frame.f_globals:
|
||||
module_name = frame.f_globals["__name__"].rsplit(".", 1)[0]
|
||||
|
||||
return (filename, line_number, function_name, module_name)
|
||||
return ("unknown", 0, "unknown", "unknown")
|
||||
finally:
|
||||
del frame # 避免循环引用
|
||||
|
||||
|
||||
# 便捷日志记录函数
|
||||
def debug(msg, *args, stack_level=0, **kwargs):
|
||||
"""
|
||||
记录DEBUG级别日志
|
||||
|
||||
Args:
|
||||
msg: 日志消息
|
||||
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
|
||||
*args, **kwargs: 传递给logger.debug的其他参数
|
||||
"""
|
||||
# 获取调用者信息
|
||||
if stack_level > 0:
|
||||
caller_info = _get_caller_info(stack_level)
|
||||
extra = kwargs.get("extra", {})
|
||||
extra["custom_stack_info"] = caller_info
|
||||
kwargs["extra"] = extra
|
||||
logger.debug(msg, *args, **kwargs)
|
||||
|
||||
|
||||
def info(msg, *args, stack_level=0, **kwargs):
|
||||
"""
|
||||
记录INFO级别日志
|
||||
|
||||
Args:
|
||||
msg: 日志消息
|
||||
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
|
||||
*args, **kwargs: 传递给logger.info的其他参数
|
||||
"""
|
||||
if stack_level > 0:
|
||||
caller_info = _get_caller_info(stack_level)
|
||||
extra = kwargs.get("extra", {})
|
||||
extra["custom_stack_info"] = caller_info
|
||||
kwargs["extra"] = extra
|
||||
logger.info(msg, *args, **kwargs)
|
||||
|
||||
|
||||
def warning(msg, *args, stack_level=0, **kwargs):
|
||||
"""
|
||||
记录WARNING级别日志
|
||||
|
||||
Args:
|
||||
msg: 日志消息
|
||||
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
|
||||
*args, **kwargs: 传递给logger.warning的其他参数
|
||||
"""
|
||||
if stack_level > 0:
|
||||
caller_info = _get_caller_info(stack_level)
|
||||
extra = kwargs.get("extra", {})
|
||||
extra["custom_stack_info"] = caller_info
|
||||
kwargs["extra"] = extra
|
||||
logger.warning(msg, *args, **kwargs)
|
||||
|
||||
|
||||
def error(msg, *args, stack_level=0, **kwargs):
|
||||
"""
|
||||
记录ERROR级别日志
|
||||
|
||||
Args:
|
||||
msg: 日志消息
|
||||
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
|
||||
*args, **kwargs: 传递给logger.error的其他参数
|
||||
"""
|
||||
if stack_level > 0:
|
||||
caller_info = _get_caller_info(stack_level)
|
||||
extra = kwargs.get("extra", {})
|
||||
extra["custom_stack_info"] = caller_info
|
||||
kwargs["extra"] = extra
|
||||
logger.error(msg, *args, **kwargs)
|
||||
|
||||
|
||||
def critical(msg, *args, stack_level=0, **kwargs):
|
||||
"""
|
||||
记录CRITICAL级别日志
|
||||
|
||||
Args:
|
||||
msg: 日志消息
|
||||
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
|
||||
*args, **kwargs: 传递给logger.critical的其他参数
|
||||
"""
|
||||
if stack_level > 0:
|
||||
caller_info = _get_caller_info(stack_level)
|
||||
extra = kwargs.get("extra", {})
|
||||
extra["custom_stack_info"] = caller_info
|
||||
kwargs["extra"] = extra
|
||||
logger.critical(msg, *args, **kwargs)
|
||||
|
||||
|
||||
def trace(msg, *args, stack_level=0, **kwargs):
|
||||
"""
|
||||
记录TRACE级别日志(比DEBUG级别更低)
|
||||
|
||||
Args:
|
||||
msg: 日志消息
|
||||
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
|
||||
*args, **kwargs: 传递给logger.log的其他参数
|
||||
"""
|
||||
if stack_level > 0:
|
||||
caller_info = _get_caller_info(stack_level)
|
||||
extra = kwargs.get("extra", {})
|
||||
extra["custom_stack_info"] = caller_info
|
||||
kwargs["extra"] = extra
|
||||
logger.log(TRACE_LEVEL, msg, *args, **kwargs)
|
||||
|
||||
|
||||
logger.trace = trace
|
||||
|
||||
# 测试日志输出(如果直接运行此文件)
|
||||
if __name__ == "__main__":
|
||||
print("测试不同日志级别的颜色输出:")
|
||||
trace("这是一条跟踪日志 (TRACE级别显示为深灰色,其他文本也为深灰色)")
|
||||
debug("这是一条调试日志 (DEBUG级别显示为蓝色,其他文本为灰色)")
|
||||
info("这是一条信息日志 (INFO级别显示为绿色,其他文本为白色)")
|
||||
warning("这是一条警告日志 (WARNING级别显示为黄色,其他文本也为黄色)")
|
||||
error("这是一条错误日志 (ERROR级别显示为红色,其他文本也为红色)")
|
||||
critical("这是一条严重错误日志 (CRITICAL级别显示为紫色,其他文本也为紫色)")
|
||||
# 测试异常输出
|
||||
try:
|
||||
1 / 0
|
||||
except Exception as e:
|
||||
error(f"发生错误: {e}", exc_info=True)
|
||||
@@ -191,23 +191,9 @@ def configure_logger(loglevel=None, working_dir=None):
|
||||
|
||||
# 添加处理器到根日志记录器
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 降低第三方库的日志级别,避免过多输出
|
||||
# pymodbus 库的日志太详细,设置为 WARNING
|
||||
logging.getLogger('pymodbus').setLevel(logging.WARNING)
|
||||
logging.getLogger('pymodbus.logging').setLevel(logging.WARNING)
|
||||
logging.getLogger('pymodbus.logging.base').setLevel(logging.WARNING)
|
||||
logging.getLogger('pymodbus.logging.decoders').setLevel(logging.WARNING)
|
||||
|
||||
# websockets 库的日志输出较多,设置为 WARNING
|
||||
logging.getLogger('websockets').setLevel(logging.WARNING)
|
||||
logging.getLogger('websockets.client').setLevel(logging.WARNING)
|
||||
logging.getLogger('websockets.server').setLevel(logging.WARNING)
|
||||
|
||||
# ROS 节点的状态更新日志过于频繁,设置为 INFO
|
||||
logging.getLogger('unilabos.ros.nodes.presets.host_node').setLevel(logging.INFO)
|
||||
|
||||
# 如果指定了工作目录,添加文件处理器
|
||||
log_filepath = None
|
||||
if working_dir is not None:
|
||||
logs_dir = os.path.join(working_dir, "logs")
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
@@ -228,6 +214,7 @@ def configure_logger(loglevel=None, working_dir=None):
|
||||
|
||||
logging.getLogger("asyncio").setLevel(logging.INFO)
|
||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||
return log_filepath
|
||||
|
||||
|
||||
# 配置日志系统
|
||||
|
||||
@@ -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.19</version>
|
||||
<version>0.11.1</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>
|
||||
|
||||
Reference in New Issue
Block a user