mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-27 04:23:10 +00:00
Compare commits
46 Commits
9c6f7c7505
...
refactor/B
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edc1fe853b | ||
|
|
80272d691d | ||
|
|
0ab4027de7 | ||
|
|
5f36b6c04b | ||
|
|
d75c7f123b | ||
|
|
ed80d786c1 | ||
|
|
9de473374f | ||
|
|
dbf5df6e4d | ||
|
|
f10c0343ce | ||
|
|
8b6553bdd9 | ||
|
|
e7a4afd6b5 | ||
|
|
f18f6d82fc | ||
|
|
b7c726635c | ||
|
|
c809912fd3 | ||
|
|
d956b27e9f | ||
|
|
ff1e21fcd8 | ||
|
|
b9d9666003 | ||
|
|
d776550a4b | ||
|
|
3d8123849a | ||
|
|
d2f204c5b0 | ||
|
|
d8922884b1 | ||
|
|
427afe83d4 | ||
|
|
23c2e3b2f7 | ||
|
|
59c26265e9 | ||
|
|
4c2adea55a | ||
|
|
0f6264503a | ||
|
|
2c554182d3 | ||
|
|
6d319d91ff | ||
|
|
3155b2f97e | ||
|
|
e5e30a1c7d | ||
|
|
4e82f62327 | ||
|
|
95d3456214 | ||
|
|
38bf95b13c | ||
|
|
f2c0bec02c | ||
|
|
e0394bf414 | ||
|
|
975a56415a | ||
|
|
cadbe87e3f | ||
|
|
b993c1f590 | ||
|
|
e0fae94c10 | ||
|
|
b5cd181ac1 | ||
|
|
5c047beb83 | ||
|
|
b40c087143 | ||
|
|
7f1cc3b2a5 | ||
|
|
3f160c2049 | ||
|
|
a54e7c0f23 | ||
|
|
e5015cd5e0 |
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.18
|
||||
version: 0.10.19
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
@@ -54,7 +54,7 @@ requirements:
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.10.18
|
||||
- uni-lab::unilabos-env ==0.10.19
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.18
|
||||
version: 0.10.19
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.10.18
|
||||
version: 0.10.19
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
@@ -11,7 +11,7 @@ build:
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.10.18
|
||||
- uni-lab::unilabos ==0.10.19
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,240 +0,0 @@
|
||||
---
|
||||
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` - 蒸发操作
|
||||
@@ -1,319 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@@ -1,233 +0,0 @@
|
||||
---
|
||||
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 方法中正确清理资源
|
||||
@@ -1,357 +0,0 @@
|
||||
---
|
||||
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. **断言清晰**: 每个断言只验证一件事
|
||||
@@ -1,353 +0,0 @@
|
||||
---
|
||||
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类
|
||||
- 支持定时搅拌和持续搅拌模式
|
||||
- 添加速度验证逻辑
|
||||
```
|
||||
160
.cursor/skills/add-device/SKILL.md
Normal file
160
.cursor/skills/add-device/SKILL.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: add-device
|
||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
|
||||
---
|
||||
|
||||
# 添加新设备到 Uni-Lab-OS
|
||||
|
||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
|
||||
|
||||
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
|
||||
|
||||
---
|
||||
|
||||
## 装饰器参考
|
||||
|
||||
### @device — 设备类装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
# 单设备
|
||||
@device(
|
||||
id="my_device.vendor", # 注册表唯一标识(必填)
|
||||
category=["temperature"], # 分类标签列表(必填)
|
||||
description="设备描述", # 设备描述
|
||||
display_name="显示名称", # UI 显示名称(默认用 id)
|
||||
icon="DeviceIcon.webp", # 图标文件名
|
||||
version="1.0.0", # 版本号
|
||||
device_type="python", # "python" 或 "ros2"
|
||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
||||
model={...}, # 3D 模型配置
|
||||
hardware_interface=HardwareInterface(...), # 硬件通信接口
|
||||
)
|
||||
|
||||
# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置)
|
||||
@device(
|
||||
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
|
||||
id_meta={
|
||||
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
|
||||
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
|
||||
},
|
||||
category=["pump_and_valve"],
|
||||
)
|
||||
```
|
||||
|
||||
### @action — 动作方法装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import action
|
||||
|
||||
@action # 无参:注册为 UniLabJsonCommand 动作
|
||||
@action() # 同上
|
||||
@action(description="执行操作") # 带描述
|
||||
@action(
|
||||
action_type=HeatChill, # 指定 ROS Action 消息类型
|
||||
goal={"temperature": "temp"}, # Goal 字段映射
|
||||
feedback={}, # Feedback 字段映射
|
||||
result={}, # Result 字段映射
|
||||
handles=[...], # 动作级别端口
|
||||
goal_default={"temp": 25.0}, # Goal 默认值
|
||||
placeholder_keys={...}, # 参数占位符
|
||||
always_free=True, # 不受排队限制
|
||||
auto_prefix=True, # 强制使用 auto- 前缀
|
||||
parent=True, # 从父类 MRO 获取参数签名
|
||||
)
|
||||
```
|
||||
|
||||
**自动识别规则:**
|
||||
- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名)
|
||||
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
|
||||
- `_` 开头的方法 → 不扫描
|
||||
- `@not_action` 标记的方法 → 排除
|
||||
|
||||
### @topic_config — 状态属性配置
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
@property
|
||||
@topic_config(
|
||||
period=5.0, # 发布周期(秒),默认 5.0
|
||||
print_publish=False, # 是否打印发布日志
|
||||
qos=10, # QoS 深度,默认 10
|
||||
name="custom_name", # 自定义发布名称(默认用属性名)
|
||||
)
|
||||
def temperature(self) -> float:
|
||||
return self.data.get("temperature", 0.0)
|
||||
```
|
||||
|
||||
### 辅助装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import not_action, always_free
|
||||
|
||||
@not_action # 标记为非动作(post_init、辅助方法等)
|
||||
@always_free # 标记为不受排队限制(查询类操作)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 设备模板
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.registry.decorators import device, action, topic_config, not_action
|
||||
|
||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
||||
class MyDevice:
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
self.device_id = device_id or "my_device"
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
||||
|
||||
@not_action
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
|
||||
self._ros_node = ros_node
|
||||
|
||||
@action
|
||||
async def initialize(self) -> bool:
|
||||
self.data["status"] = "Ready"
|
||||
return True
|
||||
|
||||
@action
|
||||
async def cleanup(self) -> bool:
|
||||
self.data["status"] = "Offline"
|
||||
return True
|
||||
|
||||
@action(description="执行操作")
|
||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
||||
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
|
||||
return {"success": True}
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""无 @action → 自动注册为 'auto-get_info' 动作"""
|
||||
return {"device_id": self.device_id}
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Idle")
|
||||
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self.data.get("temperature", 0.0)
|
||||
```
|
||||
|
||||
### 要点
|
||||
|
||||
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
|
||||
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
|
||||
- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
|
||||
- 运行时状态存储在 `self.data` 字典中
|
||||
- 设备文件放在 `unilabos/devices/<category>/` 目录下
|
||||
351
.cursor/skills/add-resource/SKILL.md
Normal file
351
.cursor/skills/add-resource/SKILL.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
name: add-resource
|
||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
||||
---
|
||||
|
||||
# 添加新物料资源
|
||||
|
||||
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。
|
||||
|
||||
---
|
||||
|
||||
## 资源类型
|
||||
|
||||
| 类型 | 基类 | 用途 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
||||
|
||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
||||
|
||||
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
|
||||
|
||||
---
|
||||
|
||||
## @resource 装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
@resource(
|
||||
id="my_resource_id", # 注册表唯一标识(必填)
|
||||
category=["bottles"], # 分类标签列表(必填)
|
||||
description="资源描述",
|
||||
icon="", # 图标
|
||||
version="1.0.0",
|
||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
||||
model={...}, # 3D 模型配置
|
||||
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 创建规范
|
||||
|
||||
### 命名规则
|
||||
|
||||
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
|
||||
2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
|
||||
|
||||
### 子物料命名示例
|
||||
|
||||
```python
|
||||
# Carrier 内部的 sites 用 name 前缀
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
|
||||
|
||||
# Carrier 中放置 Bottle 时用 name 前缀
|
||||
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
|
||||
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
|
||||
|
||||
# create_homogeneous_resources 使用 name_prefix
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[...],
|
||||
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
|
||||
)
|
||||
|
||||
# Deck setup 中用仓库名称作为 name 传入
|
||||
self.warehouses = {
|
||||
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
|
||||
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
|
||||
}
|
||||
```
|
||||
|
||||
### 其他规范
|
||||
|
||||
- **max_volume 单位为 μL**:500mL = 500000
|
||||
- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
|
||||
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
|
||||
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
|
||||
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/` 下
|
||||
- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
|
||||
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
|
||||
|
||||
---
|
||||
|
||||
## 资源模板
|
||||
|
||||
### Bottle
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import resource
|
||||
from unilabos.resources.itemized_carrier import Bottle
|
||||
|
||||
|
||||
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
|
||||
def My_Reagent_Bottle(
|
||||
name: str,
|
||||
diameter: float = 70.0,
|
||||
height: float = 120.0,
|
||||
max_volume: float = 500000.0,
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="My_Reagent_Bottle",
|
||||
)
|
||||
```
|
||||
|
||||
**Bottle 参数:**
|
||||
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
|
||||
- `diameter`: 瓶体直径 (mm)
|
||||
- `height`: 瓶体高度 (mm)
|
||||
- `max_volume`: 最大容积(**μL**,500mL = 500000)
|
||||
- `barcode`: 条形码(可选)
|
||||
|
||||
### BottleCarrier
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import ResourceHolder
|
||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
|
||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3, num_items_y=2,
|
||||
dx=10.0, dy=10.0, dz=5.0,
|
||||
item_dx=42.0, item_dy=35.0,
|
||||
size_x=20.0, size_y=20.0, size_z=50.0,
|
||||
)
|
||||
# 子 site 用 name 作为前缀
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
|
||||
sites=sites, model="My_6SlotCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
|
||||
# 放置 Bottle 时用 name 作为前缀
|
||||
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
|
||||
for i in range(6):
|
||||
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
|
||||
return carrier
|
||||
```
|
||||
|
||||
### WareHouse / Deck 放置位
|
||||
|
||||
WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
|
||||
|
||||
#### WareHouse(使用 warehouse_factory)
|
||||
|
||||
```python
|
||||
from unilabos.resources.warehouse import warehouse_factory
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
|
||||
def my_warehouse_4x4(name: str) -> "WareHouse":
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
|
||||
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
|
||||
model="my_warehouse_4x4",
|
||||
col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05)
|
||||
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
|
||||
)
|
||||
```
|
||||
|
||||
`warehouse_factory` 参数说明:
|
||||
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm)
|
||||
- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量
|
||||
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
|
||||
- `layout`:影响 slot 标签和坐标映射
|
||||
- `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列)
|
||||
- `"col-major"`:A01,B01,...,A02,B02,...(列优先)
|
||||
- `"vertical-col-major"`:竖向排列,y 坐标反向
|
||||
|
||||
#### Deck 组装 WareHouse
|
||||
|
||||
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck, Coordinate
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
|
||||
class MyStation_Deck(Deck):
|
||||
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
|
||||
category="deck", setup=False, **kwargs) -> None:
|
||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
self.warehouses = {
|
||||
"堆栈1左": my_warehouse_4x4("堆栈1左"),
|
||||
"堆栈1右": my_warehouse_4x4("堆栈1右"),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
|
||||
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
|
||||
}
|
||||
for wh_name, wh in self.warehouses.items():
|
||||
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
||||
```
|
||||
|
||||
#### Site 模式(前端定向放置)
|
||||
|
||||
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
|
||||
|
||||
```python
|
||||
import collections
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pylabrobot.resources import Deck, Resource, Coordinate
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
|
||||
class MyLabDeck(Deck):
|
||||
# 根据设备台面实测批量计算各 slot 坐标偏移
|
||||
_DEFAULT_SITE_POSITIONS = [
|
||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
||||
]
|
||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
|
||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
if sites is not None:
|
||||
self.sites = [dict(s) for s in sites]
|
||||
else:
|
||||
self.sites = []
|
||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
||||
self.sites.append({
|
||||
"label": f"T{i + 1}", # 前端显示的槽位标签
|
||||
"visible": True, # 是否在前端可见
|
||||
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
|
||||
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
|
||||
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
|
||||
})
|
||||
self._ordering = collections.OrderedDict(
|
||||
(site["label"], None) for site in self.sites
|
||||
)
|
||||
|
||||
def assign_child_resource(self, resource: Resource,
|
||||
location: Optional[Coordinate] = None,
|
||||
reassign: bool = True,
|
||||
spot: Optional[int] = None):
|
||||
idx = spot
|
||||
if spot is None:
|
||||
for i, site in enumerate(self.sites):
|
||||
if site.get("label") == resource.name:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
for i in range(len(self.sites)):
|
||||
if self._get_site_resource(i) is None:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
raise ValueError(f"No available site for '{resource.name}'")
|
||||
loc = Coordinate(**self.sites[idx]["position"])
|
||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
sites_out = []
|
||||
for i, site in enumerate(self.sites):
|
||||
occupied = self._get_site_resource(i)
|
||||
sites_out.append({
|
||||
"label": site["label"],
|
||||
"visible": site.get("visible", True),
|
||||
"occupied_by": occupied.name if occupied else None,
|
||||
"position": site["position"],
|
||||
"size": site["size"],
|
||||
"content_type": site["content_type"],
|
||||
})
|
||||
data["sites"] = sites_out
|
||||
return data
|
||||
```
|
||||
|
||||
**Site 字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
|
||||
| `visible` | bool | 是否在前端可见 |
|
||||
| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 |
|
||||
| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) |
|
||||
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
|
||||
|
||||
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。
|
||||
|
||||
---
|
||||
|
||||
## 文件位置
|
||||
|
||||
```
|
||||
unilabos/resources/
|
||||
├── <project>/ # 按项目分组
|
||||
│ ├── bottles.py # Bottle 工厂函数
|
||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
||||
│ ├── warehouses.py # WareHouse 工厂函数
|
||||
│ └── decks.py # Deck 类定义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 资源可导入
|
||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
||||
|
||||
# 启动测试(AST 自动扫描)
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。
|
||||
|
||||
---
|
||||
|
||||
## 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
| 装饰器定义 | `unilabos/registry/decorators.py` |
|
||||
292
.cursor/skills/add-resource/reference.md
Normal file
292
.cursor/skills/add-resource/reference.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 资源高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 类继承体系
|
||||
|
||||
```
|
||||
PyLabRobot
|
||||
├── Resource (PLR 基类)
|
||||
│ ├── Well
|
||||
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
||||
│ ├── Deck
|
||||
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
||||
│ ├── ResourceHolder → 槽位占位符
|
||||
│ └── Container
|
||||
│ └── Battery (unilabos) → 组装好的电池
|
||||
│
|
||||
├── ItemizedCarrier (unilabos, 继承 Resource)
|
||||
│ ├── BottleCarrier (unilabos) → 瓶载架
|
||||
│ └── WareHouse (unilabos) → 堆栈仓库
|
||||
│
|
||||
├── ItemizedResource (PLR)
|
||||
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
||||
│
|
||||
└── ResourceStack (PLR)
|
||||
└── Magazine (unilabos) → 子弹夹洞位
|
||||
```
|
||||
|
||||
### Bottle 类细节
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def __init__(self, name, diameter, height, max_volume,
|
||||
size_x=0.0, size_y=0.0, size_z=0.0,
|
||||
barcode=None, category="container", model=None, **kwargs):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
||||
size_y=diameter,
|
||||
size_z=height, # PLR 用 height 作为 size_z
|
||||
max_volume=max_volume,
|
||||
category=category,
|
||||
model=model,
|
||||
bottom_type="flat",
|
||||
cross_section_type="circle"
|
||||
)
|
||||
```
|
||||
|
||||
注意 `size_x = size_y = diameter`,`size_z = height`。
|
||||
|
||||
### ItemizedCarrier 核心方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
||||
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
||||
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
||||
| `capacity` | 总槽位数 |
|
||||
| `sites` | 所有槽位字典 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 序列化与反序列化
|
||||
|
||||
### PLR ↔ UniLab 转换
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
||||
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
||||
|
||||
### `from_plr_resources` 流程
|
||||
|
||||
```
|
||||
PLR Resource
|
||||
↓ build_uuid_mapping (递归生成 UUID)
|
||||
↓ resource.serialize() → dict
|
||||
↓ resource.serialize_all_state() → states
|
||||
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
||||
ResourceTreeSet
|
||||
```
|
||||
|
||||
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
||||
|
||||
### `to_plr_resources` 流程
|
||||
|
||||
```
|
||||
ResourceTreeSet
|
||||
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
||||
↓ node_to_plr_dict (转为 PLR 字典格式)
|
||||
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
||||
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
||||
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
||||
PLR Resource
|
||||
```
|
||||
|
||||
### Bottle 序列化
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
return {**data, "diameter": self.diameter, "height": self.height}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal=False):
|
||||
barcode_data = data.pop("barcode", None)
|
||||
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
||||
if barcode_data and isinstance(barcode_data, str):
|
||||
instance.barcode = barcode_data
|
||||
return instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Bioyond 物料同步
|
||||
|
||||
### 双向转换函数
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
||||
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
||||
|
||||
### `resource_bioyond_to_plr` 流程
|
||||
|
||||
```
|
||||
Bioyond 物料列表
|
||||
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
||||
↓ 对每个物料:
|
||||
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
||||
initialize_resource({"name": unique_name, "class": model})
|
||||
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
||||
↓ 处理 detail (子物料/坐标)
|
||||
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
||||
PLR 资源列表
|
||||
```
|
||||
|
||||
### `resource_plr_to_bioyond` 流程
|
||||
|
||||
```
|
||||
PLR 资源列表
|
||||
↓ 遍历每个资源:
|
||||
载架(capacity > 1): 生成 details 子物料 + 坐标
|
||||
单瓶: 直接映射
|
||||
↓ type_mapping 查找 typeId
|
||||
↓ warehouse_mapping 查找位置 UUID
|
||||
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
||||
Bioyond 物料列表
|
||||
```
|
||||
|
||||
### BioyondResourceSynchronizer
|
||||
|
||||
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
||||
|
||||
```python
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
all_data = []
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
||||
unilab_resources = resource_bioyond_to_plr(
|
||||
all_data,
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
deck=self.workstation.deck
|
||||
)
|
||||
# 更新 deck 上的资源
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 非瓶类资源
|
||||
|
||||
### ElectrodeSheet(极片)
|
||||
|
||||
路径:`unilabos/resources/battery/electrode_sheet.py`
|
||||
|
||||
```python
|
||||
class ElectrodeSheet(ResourcePLR):
|
||||
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
||||
_unilabos_state = {
|
||||
"diameter": 0.0,
|
||||
"thickness": 0.0,
|
||||
"mass": 0.0,
|
||||
"material_type": "",
|
||||
"color": "",
|
||||
"info": "",
|
||||
}
|
||||
```
|
||||
|
||||
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
||||
|
||||
### Battery(电池)
|
||||
|
||||
```python
|
||||
class Battery(Container):
|
||||
"""组装好的电池"""
|
||||
_unilabos_state = {
|
||||
"color": "",
|
||||
"electrolyte_name": "",
|
||||
"open_circuit_voltage": 0.0,
|
||||
}
|
||||
```
|
||||
|
||||
### Magazine / MagazineHolder(子弹夹)
|
||||
|
||||
```python
|
||||
class Magazine(ResourceStack):
|
||||
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
||||
# direction, max_sheets
|
||||
|
||||
class MagazineHolder(ItemizedResource):
|
||||
"""多洞位子弹夹"""
|
||||
# hole_diameter, hole_depth, max_sheets_per_hole
|
||||
```
|
||||
|
||||
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓库工厂模式参考
|
||||
|
||||
### 实际 warehouse 工厂函数示例
|
||||
|
||||
```python
|
||||
# 行优先 4x4 仓库
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="row-major", # A01,A02,A03,A04, B01,...
|
||||
)
|
||||
|
||||
# 右侧 4x4 仓库(列名偏移)
|
||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
col_offset=4, # A05,A06,A07,A08
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
# 竖向仓库(站内试剂存放)
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1, num_items_y=2, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="vertical-col-major",
|
||||
)
|
||||
|
||||
# 行偏移(F 行开始)
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, num_items_y=5, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
||||
row_offset=row_offset, # 0→A行起,5→F行起
|
||||
layout="row-major",
|
||||
)
|
||||
```
|
||||
|
||||
### layout 类型说明
|
||||
|
||||
| layout | 命名顺序 | 适用场景 |
|
||||
|--------|---------|---------|
|
||||
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
||||
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
||||
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
||||
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
||||
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
||||
| 电池资源 | `unilabos/resources/battery/` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
626
.cursor/skills/add-workstation/SKILL.md
Normal file
626
.cursor/skills/add-workstation/SKILL.md
Normal file
@@ -0,0 +1,626 @@
|
||||
---
|
||||
name: add-workstation
|
||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
|
||||
---
|
||||
|
||||
# Uni-Lab-OS 工作站接入指南
|
||||
|
||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。
|
||||
|
||||
---
|
||||
|
||||
## 工作站类型
|
||||
|
||||
| 类型 | 基类 | 适用场景 |
|
||||
| ------------------- | ----------------- | ---------------------------------- |
|
||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
|
||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
|
||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
|
||||
|
||||
---
|
||||
|
||||
## @device 装饰器(工作站)
|
||||
|
||||
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
|
||||
|
||||
```python
|
||||
@device(
|
||||
id="my_workstation", # 注册表唯一标识(必填)
|
||||
category=["workstation"], # 分类标签
|
||||
description="我的工作站",
|
||||
)
|
||||
```
|
||||
|
||||
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。
|
||||
|
||||
---
|
||||
|
||||
## 工作站驱动模板
|
||||
|
||||
### 模板 A:基于外部系统的工作站
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.registry.decorators import device, topic_config, not_action
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
@device(id="my_workstation", category=["workstation"], description="我的工作站")
|
||||
class MyWorkstation(WorkstationBase):
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger("MyWorkstation")
|
||||
self.api_host = self.config.get("api_host", "")
|
||||
self._status = "Idle"
|
||||
|
||||
@not_action
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode"):
|
||||
super().post_init(ros_node)
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
||||
"""注册为工作站动作"""
|
||||
return {"success": True}
|
||||
|
||||
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
||||
"""注册为工作站动作"""
|
||||
return {"success": True}
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def workflow_sequence(self) -> str:
|
||||
return "[]"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def material_info(self) -> str:
|
||||
return "{}"
|
||||
```
|
||||
|
||||
### 模板 B:Protocol 工作站
|
||||
|
||||
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
||||
```
|
||||
|
||||
在图文件中配置 `protocol_type` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 子设备访问(sub_devices)
|
||||
|
||||
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
|
||||
|
||||
```python
|
||||
# 在工站驱动类的方法中访问子设备
|
||||
sub = self._ros_node.sub_devices["pump_1"]
|
||||
|
||||
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
|
||||
sub.driver_instance.some_method(arg1, arg2)
|
||||
|
||||
# .ros_node_instance — 子设备的 ROS2 节点实例
|
||||
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
|
||||
```
|
||||
|
||||
**常见用法**:
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def my_protocol(self, **kwargs):
|
||||
# 获取子设备驱动实例
|
||||
pump = self._ros_node.sub_devices["pump_1"].driver_instance
|
||||
heater = self._ros_node.sub_devices["heater_1"].driver_instance
|
||||
|
||||
# 直接调用子设备方法
|
||||
pump.aspirate(volume=100)
|
||||
heater.set_temperature(80)
|
||||
```
|
||||
|
||||
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
|
||||
|
||||
---
|
||||
|
||||
## 硬件通信接口(hardware_interface)
|
||||
|
||||
硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
|
||||
|
||||
### 工作原理
|
||||
|
||||
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`):
|
||||
|
||||
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
|
||||
|
||||
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
|
||||
|
||||
```
|
||||
hardware_interface = d.ros_node_instance._hardware_interface
|
||||
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
|
||||
```
|
||||
|
||||
1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
|
||||
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
|
||||
2. 从通信设备获取真正的 `read`/`write` 方法
|
||||
3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
|
||||
|
||||
因此:
|
||||
|
||||
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`)
|
||||
- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备)
|
||||
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
|
||||
|
||||
### HardwareInterface 参数说明
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import HardwareInterface
|
||||
|
||||
HardwareInterface(
|
||||
name="hardware_interface", # __init__ 中接收通信实例的属性名
|
||||
read="send_command", # 通信设备上暴露的读方法名
|
||||
write="send_command", # 通信设备上暴露的写方法名
|
||||
extra_info=["list_ports"], # 可选:额外暴露的方法
|
||||
)
|
||||
```
|
||||
|
||||
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
|
||||
|
||||
### 示例 1:泵(name="hardware_interface")
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, HardwareInterface
|
||||
|
||||
@device(
|
||||
id="my_pump",
|
||||
category=["pump_and_valve"],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="hardware_interface",
|
||||
read="send_command",
|
||||
write="send_command",
|
||||
),
|
||||
)
|
||||
class MyPump:
|
||||
def __init__(self, port=None, address="1", **kwargs):
|
||||
# name="hardware_interface" → 系统替换 self.hardware_interface
|
||||
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
|
||||
self.address = address
|
||||
|
||||
def send_command(self, command: str):
|
||||
full_command = f"/{self.address}{command}\r\n"
|
||||
self.hardware_interface.write(bytearray(full_command, "ascii"))
|
||||
return self.hardware_interface.read_until(b"\n")
|
||||
```
|
||||
|
||||
### 示例 2:电磁阀(name="io_device_port",自定义属性名)
|
||||
|
||||
```python
|
||||
@device(
|
||||
id="solenoid_valve",
|
||||
category=["pump_and_valve"],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
|
||||
read="read_io_coil",
|
||||
write="write_io_coil",
|
||||
),
|
||||
)
|
||||
class SolenoidValve:
|
||||
def __init__(self, io_device_port: str = None, **kwargs):
|
||||
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
|
||||
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
|
||||
```
|
||||
|
||||
### Serial 通信设备(class="serial")
|
||||
|
||||
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`:
|
||||
|
||||
```python
|
||||
from serial import Serial, SerialException
|
||||
from threading import Lock
|
||||
|
||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
||||
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self._hardware_interface = {
|
||||
"name": "hardware_interface",
|
||||
"write": "send_command",
|
||||
"read": "read_data",
|
||||
}
|
||||
self._query_lock = Lock()
|
||||
|
||||
self.hardware_interface = Serial(baudrate=baudrate, port=port)
|
||||
|
||||
BaseROS2DeviceNode.__init__(
|
||||
self, driver_instance=self, registry_name=registry_name,
|
||||
device_id=device_id, status_types={}, action_value_mappings={},
|
||||
hardware_interface=self._hardware_interface, print_publish=False,
|
||||
)
|
||||
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
|
||||
|
||||
def send_command(self, command: str):
|
||||
with self._query_lock:
|
||||
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
|
||||
return self.hardware_interface.read_until(b"\n").decode()
|
||||
|
||||
def read_data(self):
|
||||
with self._query_lock:
|
||||
return self.hardware_interface.read_until(b"\n").decode()
|
||||
```
|
||||
|
||||
在图文件中使用 `"class": "serial"` 即可创建串口代理:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"class": "serial",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "COM7", "baudrate": 9600 }
|
||||
}
|
||||
```
|
||||
|
||||
### 图文件配置
|
||||
|
||||
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"class": "workstation",
|
||||
"children": ["serial_pump", "pump_1", "pump_2"],
|
||||
"config": { "protocol_type": ["PumpTransferProtocol"] }
|
||||
},
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"class": "serial",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "COM7", "baudrate": 9600 }
|
||||
},
|
||||
{
|
||||
"id": "pump_1",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
|
||||
},
|
||||
{
|
||||
"id": "pump_2",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": { "pump_1": "port", "serial_pump": "port" }
|
||||
},
|
||||
{
|
||||
"source": "pump_2",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": { "pump_2": "port", "serial_pump": "port" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 通信协议速查
|
||||
|
||||
| 协议 | config 参数 | 依赖包 | 通信设备 class |
|
||||
| -------------------- | ------------------------------ | ---------- | -------------------------- |
|
||||
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
|
||||
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
||||
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
||||
| TCP Socket | `host`, `port` | stdlib | 自定义 |
|
||||
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
|
||||
|
||||
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
||||
|
||||
---
|
||||
|
||||
## Deck 与物料生命周期
|
||||
|
||||
### 1. Deck 入参与两种初始化模式
|
||||
|
||||
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。
|
||||
|
||||
有两种初始化模式:
|
||||
|
||||
#### init 初始化(推荐)
|
||||
|
||||
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
|
||||
|
||||
```json
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||
"_resource_child_name": "PRCXI_Deck"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### deserialize 初始化
|
||||
|
||||
`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
|
||||
|
||||
```json
|
||||
"config": {
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "YB_Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
没有特殊需求时推荐 init 初始化。
|
||||
|
||||
#### config.deck 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) |
|
||||
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
|
||||
|
||||
#### 设备 __init__ 接收
|
||||
|
||||
```python
|
||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
# deck 已经是反序列化后的 Deck 实例
|
||||
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
|
||||
```
|
||||
|
||||
#### Deck 节点(图文件中)
|
||||
|
||||
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "PRCXI_Deck",
|
||||
"parent": "PRCXI",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"children": [],
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 542, "size_y": 374, "size_z": 0,
|
||||
"category": "deck",
|
||||
"sites": [...]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
|
||||
- `children` 初始为空时,由同步器或手动初始化填充
|
||||
- `config.type` 填 Deck 类名
|
||||
|
||||
### 2. Deck 为空时自行初始化
|
||||
|
||||
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
|
||||
|
||||
```python
|
||||
@not_action
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
if self.deck and not self.deck.children:
|
||||
self._initialize_default_deck()
|
||||
|
||||
def _initialize_default_deck(self):
|
||||
from my_labware import My_TipRack, My_Plate
|
||||
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
|
||||
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
|
||||
```
|
||||
|
||||
### 3. 物料双向同步
|
||||
|
||||
当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步:
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统同步到 self.workstation.deck"""
|
||||
external_data = self._query_external_materials()
|
||||
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
|
||||
for item in external_data:
|
||||
cls = self._resolve_resource_class(item["type"])
|
||||
resource = cls(name=item["name"], **item["params"])
|
||||
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
|
||||
return True
|
||||
|
||||
def sync_to_external(self, resource) -> bool:
|
||||
"""将 UniLab 侧物料变更同步到外部系统"""
|
||||
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
|
||||
external_format = self._convert_to_external(resource)
|
||||
return self._push_to_external(external_format)
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统主动推送的变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
同步策略取决于业务场景:
|
||||
|
||||
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
|
||||
- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
|
||||
|
||||
在工作站 `post_init` 中初始化同步器:
|
||||
|
||||
```python
|
||||
@not_action
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self)
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
```
|
||||
|
||||
### 4. 序列化与持久化(serialize / serialize_state)
|
||||
|
||||
资源类需正确实现序列化,系统据此完成持久化和前端同步。
|
||||
|
||||
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
|
||||
|
||||
```python
|
||||
class MyDeck(Deck):
|
||||
def __init__(self, name, size_x, size_y, size_z,
|
||||
sites=None, # serialize() 输出的字段
|
||||
rotation=None, # serialize() 输出的字段
|
||||
barcode=None, # serialize() 输出的字段
|
||||
**kwargs): # 兜底:接受所有未知的 serialize 字段
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
# ...
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
data["sites"] = [...] # 自定义字段
|
||||
return data
|
||||
```
|
||||
|
||||
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
|
||||
|
||||
```python
|
||||
class MyPlate(Plate):
|
||||
def __init__(self, name, size_x, size_y, size_z,
|
||||
material_info=None, **kwargs):
|
||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
def serialize_state(self) -> Dict[str, Any]:
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state)
|
||||
return data
|
||||
```
|
||||
|
||||
关键要点:
|
||||
|
||||
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`)
|
||||
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
|
||||
- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None)
|
||||
|
||||
### 5. 子物料自动同步
|
||||
|
||||
子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
|
||||
|
||||
### 6. 图文件配置(参考 prcxi_9320_slim.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"type": "device",
|
||||
"class": "my_workstation",
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.resources.my_module:MyDeck",
|
||||
"_resource_child_name": "my_deck"
|
||||
},
|
||||
"host": "10.20.30.1",
|
||||
"port": 9999
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"children": [],
|
||||
"config": {
|
||||
"type": "MyLabDeck",
|
||||
"size_x": 542,
|
||||
"size_y": 374,
|
||||
"size_z": 0,
|
||||
"category": "deck",
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
```
|
||||
|
||||
Deck 节点要点:
|
||||
|
||||
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`)
|
||||
- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取)
|
||||
- `children` 初始为空(由同步器或手动初始化填充)
|
||||
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
|
||||
|
||||
---
|
||||
|
||||
## 子设备
|
||||
|
||||
子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。
|
||||
|
||||
子设备约束:
|
||||
|
||||
- 图文件中 `parent` 指向工作站 ID
|
||||
- 在工作站 `children` 数组中列出
|
||||
|
||||
---
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
|
||||
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
|
||||
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
|
||||
4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external`
|
||||
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
||||
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
||||
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup`
|
||||
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 模块可导入
|
||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
||||
|
||||
# 启动测试(AST 自动扫描)
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 现有工作站参考
|
||||
|
||||
| 工作站 | 驱动类 | 类型 |
|
||||
| -------------- | ----------------------------- | -------- |
|
||||
| Protocol 通用 | `ProtocolNode` | Protocol |
|
||||
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
|
||||
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
||||
|
||||
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。
|
||||
371
.cursor/skills/add-workstation/reference.md
Normal file
371
.cursor/skills/add-workstation/reference.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 工作站高级模式参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
||||
Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 外部系统集成模式
|
||||
|
||||
### 1.1 RPC 客户端
|
||||
|
||||
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
||||
|
||||
```python
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
|
||||
|
||||
class MySystemRPC(BaseRequest):
|
||||
"""外部系统 RPC 客户端"""
|
||||
|
||||
def __init__(self, host: str, api_key: str):
|
||||
super().__init__(host)
|
||||
self.api_key = api_key
|
||||
|
||||
def _request(self, endpoint: str, data: dict = None) -> dict:
|
||||
return self.post(
|
||||
url=f"{self.host}/api/{endpoint}",
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": data or {},
|
||||
},
|
||||
)
|
||||
|
||||
def query_status(self) -> dict:
|
||||
return self._request("status/query")
|
||||
|
||||
def create_order(self, order_data: dict) -> dict:
|
||||
return self._request("order/create", order_data)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
||||
|
||||
### 1.2 HTTP 回调服务
|
||||
|
||||
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, config=None, deck=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
http_cfg = self.config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_cfg.get("http_service_port", 8080),
|
||||
}
|
||||
self.http_service = None
|
||||
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
```
|
||||
|
||||
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
||||
|
||||
| 端点 | 调用的工作站方法 |
|
||||
|------|-----------------|
|
||||
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
||||
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
||||
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
||||
| `/report/material_change` | `process_material_change_report(report_data)` |
|
||||
| `/report/error_handling` | `handle_external_error(error_data)` |
|
||||
|
||||
实现对应方法即可接收回调:
|
||||
|
||||
```python
|
||||
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
||||
"""处理步骤完成报告"""
|
||||
step_name = report_request.data.get("stepName")
|
||||
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
||||
"""处理订单完成报告"""
|
||||
order_code = report_request.data.get("orderCode")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
||||
|
||||
### 1.3 连接监控
|
||||
|
||||
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
||||
|
||||
```python
|
||||
class ConnectionMonitor:
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 调用外部系统接口检测连接
|
||||
self.workstation.hardware_interface.ping()
|
||||
status = "online"
|
||||
except Exception:
|
||||
status = "offline"
|
||||
time.sleep(self.check_interval)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Config 结构模式
|
||||
|
||||
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
||||
|
||||
### 2.1 外部系统连接
|
||||
|
||||
```json
|
||||
{
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 HTTP 回调服务
|
||||
|
||||
```json
|
||||
{
|
||||
"http_service_config": {
|
||||
"http_service_host": "127.0.0.1",
|
||||
"http_service_port": 8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 物料类型映射
|
||||
|
||||
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"material_type_mappings": {
|
||||
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 仓库映射
|
||||
|
||||
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"仓库名": {
|
||||
"uuid": "warehouse-uuid",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-A01",
|
||||
"A02": "site-uuid-A02"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 工作流映射
|
||||
|
||||
将内部工作流名映射到外部系统的工作流 ID。
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow_mappings": {
|
||||
"internal_workflow_name": "external-workflow-uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 物料默认参数
|
||||
|
||||
```json
|
||||
{
|
||||
"material_default_parameters": {
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 资源同步机制
|
||||
|
||||
### 3.1 ResourceSynchronizer
|
||||
|
||||
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def __init__(self, workstation, api_client):
|
||||
super().__init__(workstation)
|
||||
self.api_client = api_client
|
||||
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统拉取物料到 deck"""
|
||||
external_materials = self.api_client.list_materials()
|
||||
for material in external_materials:
|
||||
plr_resource = self._convert_to_plr(material)
|
||||
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
||||
return True
|
||||
|
||||
def sync_to_external(self, plr_resource) -> bool:
|
||||
"""将 deck 中的物料变更推送到外部系统"""
|
||||
external_data = self._convert_from_plr(plr_resource)
|
||||
self.api_client.update_material(external_data)
|
||||
return True
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统推送的物料变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
### 3.2 update_resource — 上传资源树到云端
|
||||
|
||||
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
||||
|
||||
```python
|
||||
# 在 post_init 中上传初始 deck
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 在动作方法中更新特定资源
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [updated_plate]}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 工作流序列管理
|
||||
|
||||
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._workflow_sequence = []
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
"""返回 JSON 字符串,ROS 自动发布"""
|
||||
import json
|
||||
return json.dumps(self._workflow_sequence)
|
||||
|
||||
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
||||
"""添加工作流到队列"""
|
||||
self._workflow_sequence.append({
|
||||
"name": workflow_name,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
})
|
||||
return {"success": True}
|
||||
|
||||
async def clear_workflows(self) -> Dict[str, Any]:
|
||||
"""清空工作流队列"""
|
||||
self._workflow_sequence = []
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 站间物料转移
|
||||
|
||||
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
||||
|
||||
```python
|
||||
async def transfer_materials_to_another_station(
|
||||
self,
|
||||
target_device_id: str,
|
||||
transfer_groups: list,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""将物料转移到另一个工作站"""
|
||||
target_node = self._children.get(target_device_id)
|
||||
if not target_node:
|
||||
# 通过 ROS 节点查找非子设备的目标站
|
||||
pass
|
||||
|
||||
for group in transfer_groups:
|
||||
resource = self.find_resource_by_name(group["resource_name"])
|
||||
# 从本站 deck 移除
|
||||
resource.unassign()
|
||||
# 调用目标站的接收方法
|
||||
# ...
|
||||
|
||||
return {"success": True, "transferred": len(transfer_groups)}
|
||||
```
|
||||
|
||||
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
||||
|
||||
---
|
||||
|
||||
## 6. post_init 完整模式
|
||||
|
||||
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
||||
|
||||
```python
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
|
||||
# 1. 初始化外部系统客户端(此时 config 已可用)
|
||||
self.rpc_client = MySystemRPC(
|
||||
host=self.config.get("api_host"),
|
||||
api_key=self.config.get("api_key"),
|
||||
)
|
||||
self.hardware_interface = self.rpc_client
|
||||
|
||||
# 2. 启动连接监控
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
|
||||
# 3. 启动 HTTP 回调服务
|
||||
if hasattr(self, '_http_service_config'):
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
|
||||
# 4. 上传 deck 到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 5. 初始化资源同步器(可选)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
||||
```
|
||||
233
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
233
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
name: batch-insert-reagent
|
||||
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
|
||||
---
|
||||
|
||||
# 批量录入试剂 Skill
|
||||
|
||||
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token(任选一种方式):
|
||||
|
||||
```bash
|
||||
# 方式一:Python 一行生成
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
|
||||
# 方式二:手动计算
|
||||
# base64(ak:sk) → Authorization: Lab <token>
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
|-------------|------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 录入试剂
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"cas": "<CAS号>",
|
||||
"name": "<试剂名称>",
|
||||
"molecular_formula": "<分子式>",
|
||||
"smiles": "<SMILES>",
|
||||
"stock_in_quantity": <入库数量>,
|
||||
"unit": "<单位字符串>",
|
||||
"supplier": "<供应商>",
|
||||
"production_date": "<生产日期 ISO 8601>",
|
||||
"expiry_date": "<过期日期 ISO 8601>"
|
||||
}'
|
||||
```
|
||||
|
||||
返回成功时包含试剂 UUID:
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 试剂字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||
|------|------|------|------|------|
|
||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||
|
||||
### unit 单位值
|
||||
|
||||
| 值 | 单位 |
|
||||
|------|------|
|
||||
| `"mL"` | 毫升 |
|
||||
| `"L"` | 升 |
|
||||
| `"g"` | 克 |
|
||||
| `"kg"` | 千克 |
|
||||
| `"瓶"` | 瓶 |
|
||||
|
||||
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
||||
|
||||
---
|
||||
|
||||
## 批量录入策略
|
||||
|
||||
### 方式一:用户提供 JSON 数组
|
||||
|
||||
用户一次性给出多条试剂数据:
|
||||
|
||||
```json
|
||||
[
|
||||
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
|
||||
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
|
||||
]
|
||||
```
|
||||
|
||||
Agent 自动为每条补充 `lab_uuid`、`production_date`、`expiry_date` 等字段后逐条提交。
|
||||
|
||||
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
|
||||
|
||||
### 方式二:用户逐个描述
|
||||
|
||||
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇,Sigma 的」),agent 自行补全字段:
|
||||
|
||||
1. 根据名称查找 CAS 号、分子式、SMILES(参考下方速查表或自行推断)
|
||||
2. 构建完整的请求体
|
||||
3. 向用户确认后提交
|
||||
|
||||
### 方式三:从 CSV/表格批量导入
|
||||
|
||||
用户提供 CSV 或表格文件路径,agent 读取并解析:
|
||||
|
||||
```bash
|
||||
# 期望的 CSV 格式(首行为表头)
|
||||
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
|
||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
||||
```
|
||||
|
||||
### 执行与汇报
|
||||
|
||||
每次 API 调用后:
|
||||
1. 检查返回 `code`(0 = 成功)
|
||||
2. 记录成功/失败数量
|
||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
||||
4. 如有失败,列出失败的试剂名称和错误信息
|
||||
|
||||
---
|
||||
|
||||
## 常见试剂速查表
|
||||
|
||||
| 名称 | CAS | 分子式 | SMILES |
|
||||
|------|-----|--------|--------|
|
||||
| 水 | 7732-18-3 | H2O | O |
|
||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||
|
||||
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件)
|
||||
- [ ] Step 5: 补全缺失字段(CAS、分子式、SMILES 等)
|
||||
- [ ] Step 6: 向用户确认待录入的试剂列表
|
||||
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid)
|
||||
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整示例
|
||||
|
||||
用户说:「帮我录入 3 种试剂:500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
|
||||
|
||||
Agent 构建的请求序列:
|
||||
|
||||
```json
|
||||
// 第 1 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
|
||||
// 第 2 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
|
||||
// 第 3 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
```
|
||||
301
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
301
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
@@ -0,0 +1,301 @@
|
||||
---
|
||||
name: batch-submit-experiment
|
||||
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds. Use when the user wants to submit experiments, create notebooks, batch run workflows, or mentions 提交实验/批量实验/notebook/实验轮次.
|
||||
---
|
||||
|
||||
# 批量提交实验指南
|
||||
|
||||
通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token(任选一种方式):
|
||||
|
||||
```bash
|
||||
# 方式一:Python 一行生成
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
|
||||
# 方式二:手动计算
|
||||
# base64(ak:sk) → Authorization: Lab <token>
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
|-------------|------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
### 3. req_device_registry_upload.json(设备注册表)
|
||||
|
||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
||||
|
||||
按优先级搜索:
|
||||
|
||||
```
|
||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
||||
<workspace 根目录>/req_device_registry_upload.json
|
||||
```
|
||||
|
||||
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
|
||||
|
||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||
|
||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||
|
||||
### 4. workflow_uuid(目标工作流)
|
||||
|
||||
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #2 列出可用 workflow 供选择。
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #3 获取)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
>
|
||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 列出可用 workflow
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。
|
||||
|
||||
### 3. 获取 workflow 模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||
- 每个 action 节点的 `node_uuid`
|
||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||
- 每个节点的动作名(`node_template_name`)
|
||||
- 每个节点的现有参数(`param`)
|
||||
|
||||
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
|
||||
|
||||
### 4. 提交实验(创建 notebook)
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
{
|
||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||
"datas": [
|
||||
{
|
||||
"node_uuid": "<workflow中的节点UUID>",
|
||||
"param": {},
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "<容器UUID>",
|
||||
"sample_value": {
|
||||
"liquid_names": "<液体名称>",
|
||||
"volumes": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
|
||||
|
||||
---
|
||||
|
||||
## Notebook 请求体详解
|
||||
|
||||
### node_params 结构
|
||||
|
||||
`node_params` 是一个数组,**每个元素代表一轮实验**:
|
||||
|
||||
- 要跑 2 轮 → `node_params` 有 2 个元素
|
||||
- 要跑 N 轮 → `node_params` 有 N 个元素
|
||||
|
||||
### 每轮的字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||
|
||||
### datas 中每个节点
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #3 获取) |
|
||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||
|
||||
### sample_params 中每条
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `container_uuid` | string | 容器 UUID |
|
||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||
|
||||
---
|
||||
|
||||
## 从本地注册表生成 param 模板
|
||||
|
||||
### 自动方式 — 运行脚本
|
||||
|
||||
```bash
|
||||
python scripts/gen_notebook_params.py \
|
||||
--auth <token> \
|
||||
--base <BASE_URL> \
|
||||
--workflow-uuid <workflow_uuid> \
|
||||
[--registry <path/to/req_device_registry_upload.json>] \
|
||||
[--rounds <轮次数>] \
|
||||
[--output <输出文件路径>]
|
||||
```
|
||||
|
||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
||||
|
||||
脚本会:
|
||||
1. 调用 workflow detail API 获取所有 action 节点
|
||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
||||
3. 生成 `notebook_template.json`,包含:
|
||||
- 完整 `node_params` 骨架
|
||||
- 每个节点的 param 字段及类型说明
|
||||
- `_schema_info` 辅助信息(不提交,仅供参考)
|
||||
|
||||
### 手动方式
|
||||
|
||||
如果脚本不可用或注册表不存在:
|
||||
|
||||
1. 调用 API #3 获取 workflow 详情
|
||||
2. 找到每个 action 节点的 `node_uuid`
|
||||
3. 在本地注册表中查找对应设备的 `action_value_mappings`:
|
||||
```
|
||||
resources[].id == <device_id>
|
||||
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
|
||||
```
|
||||
4. 将 schema 中的 properties 作为 `param` 的字段模板
|
||||
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
|
||||
|
||||
### 注册表结构参考
|
||||
|
||||
```json
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"id": "liquid_handler.prcxi",
|
||||
"class": {
|
||||
"module": "unilabos.devices.xxx:ClassName",
|
||||
"action_value_mappings": {
|
||||
"transfer_liquid": {
|
||||
"type": "LiquidHandlerTransfer",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"goal": {
|
||||
"properties": {
|
||||
"asp_vols": {"type": "array", "items": {"type": "number"}},
|
||||
"sources": {"type": "array"}
|
||||
},
|
||||
"required": ["asp_vols", "sources"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"goal_default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: 确认 workflow_uuid(用户提供或从 GET #2 列表选择)
|
||||
- [ ] Step 5: GET workflow detail (#3) → 提取各节点 uuid、设备ID、动作名
|
||||
- [ ] Step 6: 定位本地注册表 req_device_registry_upload.json
|
||||
- [ ] Step 7: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
||||
- [ ] Step 8: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
||||
- [ ] Step 9: 构建完整请求体 → POST /lab/notebook 提交
|
||||
- [ ] Step 10: 检查返回结果,确认提交成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
|
||||
|
||||
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
|
||||
|
||||
### Q: 多轮实验的参数完全不同吗?
|
||||
|
||||
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
|
||||
|
||||
### Q: 如何获取 sample_uuids 和 container_uuid?
|
||||
|
||||
这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。
|
||||
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
|
||||
|
||||
用法:
|
||||
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
|
||||
|
||||
选项:
|
||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
||||
--base <url> API 基础 URL(如 https://uni-lab.test.bohrium.com)
|
||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
||||
--rounds <n> 实验轮次数(默认 1)
|
||||
--output <path> 输出模板文件路径(默认 notebook_template.json)
|
||||
--dump-response 打印 workflow detail API 的原始响应(调试用)
|
||||
|
||||
示例:
|
||||
python gen_notebook_params.py \\
|
||||
--auth YTFmZDlkNGUtxxxx \\
|
||||
--base https://uni-lab.test.bohrium.com \\
|
||||
--workflow-uuid abc-123-def \\
|
||||
--rounds 2
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
||||
|
||||
|
||||
def find_registry(explicit_path=None):
|
||||
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
|
||||
if explicit_path:
|
||||
if os.path.isfile(explicit_path):
|
||||
return explicit_path
|
||||
if os.path.isdir(explicit_path):
|
||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
||||
if os.path.isfile(fp):
|
||||
return fp
|
||||
print(f"警告: 指定的注册表路径不存在: {explicit_path}")
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.isfile(c):
|
||||
return c
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
||||
for c in candidates:
|
||||
path = os.path.join(workspace_root, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
cwd = os.getcwd()
|
||||
for _ in range(5):
|
||||
parent = os.path.dirname(cwd)
|
||||
if parent == cwd:
|
||||
break
|
||||
cwd = parent
|
||||
for c in candidates:
|
||||
path = os.path.join(cwd, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def load_registry(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def build_registry_index(registry_data):
|
||||
"""构建 device_id → action_value_mappings 的索引"""
|
||||
index = {}
|
||||
for res in registry_data.get("resources", []):
|
||||
rid = res.get("id", "")
|
||||
avm = res.get("class", {}).get("action_value_mappings", {})
|
||||
if rid and avm:
|
||||
index[rid] = avm
|
||||
return index
|
||||
|
||||
|
||||
def flatten_goal_schema(action_data):
|
||||
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
|
||||
schema = action_data.get("schema", {})
|
||||
goal_schema = schema.get("properties", {}).get("goal", {})
|
||||
return goal_schema if goal_schema else schema
|
||||
|
||||
|
||||
def build_param_template(goal_schema):
|
||||
"""根据 goal schema 生成 param 模板,含类型标注"""
|
||||
properties = goal_schema.get("properties", {})
|
||||
required = set(goal_schema.get("required", []))
|
||||
template = {}
|
||||
for field_name, field_def in properties.items():
|
||||
if field_name == "unilabos_device_id":
|
||||
continue
|
||||
ftype = field_def.get("type", "any")
|
||||
default = field_def.get("default")
|
||||
if default is not None:
|
||||
template[field_name] = default
|
||||
elif ftype == "string":
|
||||
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
|
||||
elif ftype == "number" or ftype == "integer":
|
||||
template[field_name] = 0
|
||||
elif ftype == "boolean":
|
||||
template[field_name] = False
|
||||
elif ftype == "array":
|
||||
template[field_name] = []
|
||||
elif ftype == "object":
|
||||
template[field_name] = {}
|
||||
else:
|
||||
template[field_name] = f"$TODO ({ftype})"
|
||||
return template
|
||||
|
||||
|
||||
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
|
||||
"""调用 workflow detail API"""
|
||||
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
|
||||
req = Request(url, method="GET")
|
||||
req.add_header("Authorization", f"Lab {auth_token}")
|
||||
try:
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(f"API 错误 {e.code}: {body}")
|
||||
return None
|
||||
except URLError as e:
|
||||
print(f"网络错误: {e.reason}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_nodes_from_response(response):
|
||||
"""
|
||||
从 workflow detail 响应中提取 action 节点列表。
|
||||
适配多种可能的响应格式。
|
||||
|
||||
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
|
||||
"""
|
||||
data = response.get("data", response)
|
||||
|
||||
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
|
||||
nodes_raw = None
|
||||
for key in search_keys:
|
||||
if key in data and isinstance(data[key], list):
|
||||
nodes_raw = data[key]
|
||||
break
|
||||
|
||||
if nodes_raw is None:
|
||||
if isinstance(data, list):
|
||||
nodes_raw = data
|
||||
else:
|
||||
for v in data.values():
|
||||
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
||||
nodes_raw = v
|
||||
break
|
||||
|
||||
if not nodes_raw:
|
||||
print("警告: 未能从响应中提取节点列表")
|
||||
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
|
||||
return []
|
||||
|
||||
result = []
|
||||
for node in nodes_raw:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
node_uuid = (
|
||||
node.get("uuid")
|
||||
or node.get("node_uuid")
|
||||
or node.get("id")
|
||||
or ""
|
||||
)
|
||||
resource_name = (
|
||||
node.get("resource_template_name")
|
||||
or node.get("device_id")
|
||||
or node.get("resource_name")
|
||||
or node.get("device_name")
|
||||
or ""
|
||||
)
|
||||
template_name = (
|
||||
node.get("node_template_name")
|
||||
or node.get("action_name")
|
||||
or node.get("template_name")
|
||||
or node.get("action")
|
||||
or node.get("name")
|
||||
or ""
|
||||
)
|
||||
existing_param = node.get("param", {}) or {}
|
||||
|
||||
if node_uuid:
|
||||
result.append((node_uuid, resource_name, template_name, existing_param))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_template(nodes, registry_index, rounds):
|
||||
"""生成 notebook 提交模板"""
|
||||
node_params = []
|
||||
schema_info = {}
|
||||
|
||||
datas_template = []
|
||||
for node_uuid, resource_name, template_name, existing_param in nodes:
|
||||
param_template = {}
|
||||
matched = False
|
||||
|
||||
if resource_name and template_name and resource_name in registry_index:
|
||||
avm = registry_index[resource_name]
|
||||
if template_name in avm:
|
||||
goal_schema = flatten_goal_schema(avm[template_name])
|
||||
param_template = build_param_template(goal_schema)
|
||||
goal_default = avm[template_name].get("goal_default", {})
|
||||
if goal_default:
|
||||
for k, v in goal_default.items():
|
||||
if k in param_template and v is not None:
|
||||
param_template[k] = v
|
||||
matched = True
|
||||
|
||||
schema_info[node_uuid] = {
|
||||
"device_id": resource_name,
|
||||
"action_name": template_name,
|
||||
"action_type": avm[template_name].get("type", ""),
|
||||
"schema_properties": list(goal_schema.get("properties", {}).keys()),
|
||||
"required": goal_schema.get("required", []),
|
||||
}
|
||||
|
||||
if not matched and existing_param:
|
||||
param_template = existing_param
|
||||
|
||||
if not matched and not existing_param:
|
||||
schema_info[node_uuid] = {
|
||||
"device_id": resource_name,
|
||||
"action_name": template_name,
|
||||
"warning": "未在本地注册表中找到匹配的 action schema",
|
||||
}
|
||||
|
||||
datas_template.append({
|
||||
"node_uuid": node_uuid,
|
||||
"param": param_template,
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "$TODO_CONTAINER_UUID",
|
||||
"sample_value": {
|
||||
"liquid_names": "$TODO_LIQUID_NAME",
|
||||
"volumes": 0,
|
||||
},
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
for i in range(rounds):
|
||||
node_params.append({
|
||||
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
|
||||
"datas": copy.deepcopy(datas_template),
|
||||
})
|
||||
|
||||
return {
|
||||
"lab_uuid": "$TODO_LAB_UUID",
|
||||
"workflow_uuid": "$TODO_WORKFLOW_UUID",
|
||||
"name": "$TODO_EXPERIMENT_NAME",
|
||||
"node_params": node_params,
|
||||
"_schema_info(仅参考,提交时删除)": schema_info,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
"""简单的参数解析"""
|
||||
opts = {
|
||||
"auth": None,
|
||||
"base": None,
|
||||
"workflow_uuid": None,
|
||||
"registry": None,
|
||||
"rounds": 1,
|
||||
"output": "notebook_template.json",
|
||||
"dump_response": False,
|
||||
}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--auth" and i + 1 < len(argv):
|
||||
opts["auth"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--base" and i + 1 < len(argv):
|
||||
opts["base"] = argv[i + 1].rstrip("/")
|
||||
i += 2
|
||||
elif arg == "--workflow-uuid" and i + 1 < len(argv):
|
||||
opts["workflow_uuid"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--registry" and i + 1 < len(argv):
|
||||
opts["registry"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--rounds" and i + 1 < len(argv):
|
||||
opts["rounds"] = int(argv[i + 1])
|
||||
i += 2
|
||||
elif arg == "--output" and i + 1 < len(argv):
|
||||
opts["output"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--dump-response":
|
||||
opts["dump_response"] = True
|
||||
i += 1
|
||||
else:
|
||||
print(f"未知参数: {arg}")
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
def main():
|
||||
opts = parse_args(sys.argv[1:])
|
||||
|
||||
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
|
||||
print("用法:")
|
||||
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
|
||||
print()
|
||||
print("必需参数:")
|
||||
print(" --auth <token> Lab token(base64(ak:sk))")
|
||||
print(" --base <url> API 基础 URL")
|
||||
print(" --workflow-uuid <uuid> 目标 workflow UUID")
|
||||
print()
|
||||
print("可选参数:")
|
||||
print(" --registry <path> 注册表文件路径(默认自动搜索)")
|
||||
print(" --rounds <n> 实验轮次数(默认 1)")
|
||||
print(" --output <path> 输出文件路径(默认 notebook_template.json)")
|
||||
print(" --dump-response 打印 API 原始响应")
|
||||
sys.exit(1)
|
||||
|
||||
# 1. 查找并加载本地注册表
|
||||
registry_path = find_registry(opts["registry"])
|
||||
registry_index = {}
|
||||
if registry_path:
|
||||
mtime = os.path.getmtime(registry_path)
|
||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"注册表: {registry_path} (生成时间: {gen_time})")
|
||||
registry_data = load_registry(registry_path)
|
||||
registry_index = build_registry_index(registry_data)
|
||||
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
|
||||
else:
|
||||
print("警告: 未找到本地注册表,将跳过 param 模板生成")
|
||||
print(" 提交时需要手动填写各节点的 param 字段")
|
||||
|
||||
# 2. 获取 workflow 详情
|
||||
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
|
||||
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
|
||||
if not response:
|
||||
print("错误: 无法获取 workflow 详情")
|
||||
sys.exit(1)
|
||||
|
||||
if opts["dump_response"]:
|
||||
print("\n=== API 原始响应 ===")
|
||||
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
|
||||
print("=== 响应结束(截断至 5000 字符) ===\n")
|
||||
|
||||
# 3. 提取节点
|
||||
nodes = extract_nodes_from_response(response)
|
||||
if not nodes:
|
||||
print("错误: 未能从 workflow 中提取任何 action 节点")
|
||||
print("请使用 --dump-response 查看原始响应结构")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n找到 {len(nodes)} 个 action 节点:")
|
||||
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
|
||||
print(" " + "-" * 110)
|
||||
for node_uuid, resource_name, template_name, _ in nodes:
|
||||
matched = "✓" if (resource_name in registry_index and
|
||||
template_name in registry_index.get(resource_name, {})) else "✗"
|
||||
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
|
||||
|
||||
# 4. 生成模板
|
||||
template = generate_template(nodes, registry_index, opts["rounds"])
|
||||
template["workflow_uuid"] = opts["workflow_uuid"]
|
||||
|
||||
output_path = opts["output"]
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
print(f"\n模板已写入: {output_path}")
|
||||
print(f" 轮次数: {opts['rounds']}")
|
||||
print(f" 节点数/轮: {len(nodes)}")
|
||||
print()
|
||||
print("下一步:")
|
||||
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
|
||||
print(" 2. 删除 _schema_info 字段(仅供参考)")
|
||||
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
328
.cursor/skills/create-device-skill/SKILL.md
Normal file
328
.cursor/skills/create-device-skill/SKILL.md
Normal file
@@ -0,0 +1,328 @@
|
||||
---
|
||||
name: create-device-skill
|
||||
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
|
||||
---
|
||||
|
||||
# 创建设备 Skill 指南
|
||||
|
||||
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
|
||||
|
||||
## 数据源
|
||||
|
||||
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
|
||||
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
|
||||
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
|
||||
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
|
||||
|
||||
## 创建流程
|
||||
|
||||
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
|
||||
|
||||
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
|
||||
|
||||
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
|
||||
|
||||
#### 必备项 ①:ak / sk(认证凭据)
|
||||
|
||||
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。
|
||||
|
||||
获取后立即生成 AUTH token:
|
||||
|
||||
```bash
|
||||
python ./scripts/gen_auth.py <ak> <sk>
|
||||
# 或从 config.py 提取
|
||||
python ./scripts/gen_auth.py --config <config.py>
|
||||
```
|
||||
|
||||
认证算法:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
#### 必备项 ②:--addr(目标环境)
|
||||
|
||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||
|
||||
| `--addr` 值 | BASE URL |
|
||||
|-------------|----------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
|
||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||
|
||||
数据文件由 `unilab` 启动时自动生成,需要定位它:
|
||||
|
||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||
|
||||
| 条件 | working_dir 取值 |
|
||||
|------|------------------|
|
||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
|
||||
**按优先级搜索文件**:
|
||||
|
||||
```
|
||||
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
|
||||
<推断的 working_dir>/req_device_registry_upload.json
|
||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
||||
```
|
||||
|
||||
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
|
||||
|
||||
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||
|
||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
|
||||
|
||||
#### 必备项 ④:目标设备
|
||||
|
||||
用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。
|
||||
|
||||
如果用户不确定,运行提取脚本列出所有设备供选择:
|
||||
|
||||
```bash
|
||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||
```
|
||||
|
||||
#### 完整示例
|
||||
|
||||
用户提供:
|
||||
|
||||
```
|
||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
||||
--addr test
|
||||
--port 8003
|
||||
--disable_browser
|
||||
```
|
||||
|
||||
从中提取:
|
||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
||||
|
||||
**四项全部就绪后才进入 Step 1。**
|
||||
|
||||
### Step 1 — 列出可用设备
|
||||
|
||||
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
|
||||
|
||||
```bash
|
||||
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
|
||||
python ./scripts/extract_device_actions.py
|
||||
|
||||
# 指定注册表文件路径
|
||||
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
||||
```
|
||||
|
||||
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
|
||||
|
||||
### Step 2 — 提取 Action Schema
|
||||
|
||||
用户选择设备后,运行提取脚本:
|
||||
|
||||
```bash
|
||||
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
|
||||
```
|
||||
|
||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||
|
||||
每个 action 生成一个 JSON 文件,包含:
|
||||
- `type` — 作为 API 调用的 `action_type`
|
||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||
- `goal_default` — 默认值
|
||||
|
||||
### Step 3 — 写 action-index.md
|
||||
|
||||
按模板为每个 action 写条目:
|
||||
|
||||
```markdown
|
||||
### `<action_name>`
|
||||
|
||||
<用途描述(一句话)>
|
||||
|
||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||
- **可选参数**: `param3`, `param4`
|
||||
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
|
||||
```
|
||||
|
||||
描述规则:
|
||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||
- 从 `schema.required` 区分核心/可选参数
|
||||
- 按功能分类(移液、枪头、外设等)
|
||||
- 标注 `placeholder_keys` 中的字段类型:
|
||||
- `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点)
|
||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
||||
|
||||
### Step 4 — 写 SKILL.md
|
||||
|
||||
直接复用 `unilab-device-api` 的 API 模板(10 个 endpoint),修改:
|
||||
- 设备名称
|
||||
- Action 数量
|
||||
- 目录列表
|
||||
- Session state 中的 `device_name`
|
||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||
|
||||
API 模板结构:
|
||||
|
||||
```markdown
|
||||
## 设备信息
|
||||
- device_id, Python 源码路径, 设备类名
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
- ak/sk → AUTH, --addr → BASE URL
|
||||
|
||||
## Session State
|
||||
- lab_uuid(通过 API #1 自动匹配,不要问用户), device_name
|
||||
|
||||
## API Endpoints (10 个)
|
||||
# 注意:
|
||||
# - #1 获取 lab 列表 + 自动匹配 lab_uuid(遍历 is_admin 的 lab,
|
||||
# 调用 /lab/info/{uuid} 比对 access_key == ak)
|
||||
# - #2 创建工作流用 POST /lab/workflow
|
||||
# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid}
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
||||
- 列出本设备所有 Slot 字段、类型及含义
|
||||
|
||||
## 渐进加载策略
|
||||
## 完整工作流 Checklist
|
||||
```
|
||||
|
||||
### Step 5 — 验证
|
||||
|
||||
检查文件完整性:
|
||||
- [ ] `SKILL.md` 包含 10 个 API endpoint
|
||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
||||
- [ ] 描述能让 agent 判断该用哪个 action
|
||||
|
||||
## Action JSON 文件结构
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "LiquidHandlerTransfer", // → API 的 action_type
|
||||
"goal": { // goal 字段映射
|
||||
"sources": "sources",
|
||||
"targets": "targets",
|
||||
"tip_racks": "tip_racks",
|
||||
"asp_vols": "asp_vols"
|
||||
},
|
||||
"schema": { // ← 直接是 goal 的 schema(已提升)
|
||||
"type": "object",
|
||||
"properties": { // 参数定义(即请求中 goal 的字段)
|
||||
"sources": { "type": "array", "items": { "type": "object" } },
|
||||
"targets": { "type": "array", "items": { "type": "object" } },
|
||||
"asp_vols": { "type": "array", "items": { "type": "number" } }
|
||||
},
|
||||
"required": [...],
|
||||
"_unilabos_placeholder_info": { // ← Slot 类型标记
|
||||
"sources": "unilabos_resources",
|
||||
"targets": "unilabos_resources",
|
||||
"tip_racks": "unilabos_resources"
|
||||
}
|
||||
},
|
||||
"goal_default": { ... }, // 默认值
|
||||
"placeholder_keys": { // ← 汇总所有 Slot 字段
|
||||
"sources": "unilabos_resources", // ResourceSlot
|
||||
"targets": "unilabos_resources",
|
||||
"tip_racks": "unilabos_resources",
|
||||
"target_device_id": "unilabos_devices" // DeviceSlot
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
||||
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段。
|
||||
|
||||
## Placeholder Slot 类型体系
|
||||
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
|
||||
|
||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
|---------------|-----------|---------|---------|
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||
|
||||
### ResourceSlot(`unilabos_resources`)
|
||||
|
||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||
|
||||
```json
|
||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
||||
```
|
||||
|
||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||
- `id` 本身是从 parent 计算的路径格式
|
||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
||||
|
||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
||||
|
||||
### DeviceSlot(`unilabos_devices`)
|
||||
|
||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
||||
|
||||
```
|
||||
"/host_node"
|
||||
"/bioyond_cell/reaction_station"
|
||||
```
|
||||
|
||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
||||
|
||||
### NodeSlot(`unilabos_nodes`)
|
||||
|
||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
||||
|
||||
```
|
||||
"/PRCXI/PRCXI_Deck"
|
||||
```
|
||||
|
||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
||||
|
||||
### ClassSlot(`unilabos_class`)
|
||||
|
||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
||||
|
||||
```
|
||||
"container"
|
||||
```
|
||||
|
||||
### 通过 API #10 获取资源树
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
||||
|
||||
## 最终目录结构
|
||||
|
||||
```
|
||||
./<skill-name>/
|
||||
├── SKILL.md # API 端点 + 渐进加载指引
|
||||
├── action-index.md # 动作索引:描述/用途/核心参数
|
||||
└── actions/ # 每个 action 的完整 JSON Schema
|
||||
├── action1.json
|
||||
├── action2.json
|
||||
└── ...
|
||||
```
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 req_device_registry_upload.json 中提取指定设备的 action schema。
|
||||
|
||||
用法:
|
||||
# 列出所有设备及 action 数量(自动搜索注册表文件)
|
||||
python extract_device_actions.py
|
||||
|
||||
# 指定注册表文件路径
|
||||
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
||||
|
||||
# 提取指定设备的 action 到目录
|
||||
python extract_device_actions.py <device_id> <output_dir>
|
||||
python extract_device_actions.py --registry <path> <device_id> <output_dir>
|
||||
|
||||
示例:
|
||||
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
|
||||
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
||||
|
||||
def find_registry(explicit_path=None):
|
||||
"""
|
||||
查找 req_device_registry_upload.json 文件。
|
||||
|
||||
搜索优先级:
|
||||
1. 用户通过 --registry 显式指定的路径
|
||||
2. <cwd>/unilabos_data/req_device_registry_upload.json
|
||||
3. <cwd>/req_device_registry_upload.json
|
||||
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
|
||||
5. 向上逐级搜索父目录(最多 5 层)
|
||||
"""
|
||||
if explicit_path:
|
||||
if os.path.isfile(explicit_path):
|
||||
return explicit_path
|
||||
if os.path.isdir(explicit_path):
|
||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
||||
if os.path.isfile(fp):
|
||||
return fp
|
||||
print(f"警告: 指定的路径不存在: {explicit_path}")
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
]
|
||||
|
||||
for c in candidates:
|
||||
if os.path.isfile(c):
|
||||
return c
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
||||
for c in candidates:
|
||||
path = os.path.join(workspace_root, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
cwd = os.getcwd()
|
||||
for _ in range(5):
|
||||
parent = os.path.dirname(cwd)
|
||||
if parent == cwd:
|
||||
break
|
||||
cwd = parent
|
||||
for c in candidates:
|
||||
path = os.path.join(cwd, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def load_registry(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def list_devices(data):
|
||||
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
|
||||
resources = data.get('resources', [])
|
||||
devices = []
|
||||
for res in resources:
|
||||
rid = res.get('id', '')
|
||||
cls = res.get('class', {})
|
||||
avm = cls.get('action_value_mappings', {})
|
||||
module = cls.get('module', '')
|
||||
if avm:
|
||||
devices.append((rid, len(avm), module))
|
||||
return devices
|
||||
|
||||
def flatten_schema_to_goal(action_data):
|
||||
"""将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装"""
|
||||
schema = action_data.get('schema', {})
|
||||
goal_schema = schema.get('properties', {}).get('goal', {})
|
||||
if goal_schema:
|
||||
action_data = dict(action_data)
|
||||
action_data['schema'] = goal_schema
|
||||
return action_data
|
||||
|
||||
|
||||
def extract_actions(data, device_id, output_dir):
|
||||
"""提取指定设备的 action schema 到独立 JSON 文件"""
|
||||
resources = data.get('resources', [])
|
||||
for res in resources:
|
||||
if res.get('id') == device_id:
|
||||
cls = res.get('class', {})
|
||||
module = cls.get('module', '')
|
||||
avm = cls.get('action_value_mappings', {})
|
||||
if not avm:
|
||||
print(f"设备 {device_id} 没有 action_value_mappings")
|
||||
return []
|
||||
|
||||
if module:
|
||||
py_path = module.split(":")[0].replace(".", "/") + ".py"
|
||||
class_name = module.split(":")[-1] if ":" in module else ""
|
||||
print(f"Python 源码: {py_path}")
|
||||
if class_name:
|
||||
print(f"设备类: {class_name}")
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
written = []
|
||||
for action_name in sorted(avm.keys()):
|
||||
action_data = flatten_schema_to_goal(avm[action_name])
|
||||
filename = action_name.replace('-', '_') + '.json'
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(action_data, f, indent=2, ensure_ascii=False)
|
||||
written.append(filename)
|
||||
print(f" {filepath}")
|
||||
return written
|
||||
|
||||
print(f"设备 {device_id} 未找到")
|
||||
return []
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
explicit_registry = None
|
||||
|
||||
if "--registry" in args:
|
||||
idx = args.index("--registry")
|
||||
if idx + 1 < len(args):
|
||||
explicit_registry = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
else:
|
||||
print("错误: --registry 需要指定路径")
|
||||
sys.exit(1)
|
||||
|
||||
registry_path = find_registry(explicit_registry)
|
||||
if not registry_path:
|
||||
print(f"错误: 找不到 {REGISTRY_FILENAME}")
|
||||
print()
|
||||
print("解决方法:")
|
||||
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
|
||||
print(" 2. 用 --registry 指定文件路径:")
|
||||
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
|
||||
print()
|
||||
print("搜索过的路径:")
|
||||
for p in [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
|
||||
]:
|
||||
print(f" - {p}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"注册表: {registry_path}")
|
||||
mtime = os.path.getmtime(registry_path)
|
||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
|
||||
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
|
||||
data = load_registry(registry_path)
|
||||
|
||||
if len(args) == 0:
|
||||
devices = list_devices(data)
|
||||
print(f"\n找到 {len(devices)} 个设备:")
|
||||
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
|
||||
print("-" * 120)
|
||||
for did, count, module in sorted(devices, key=lambda x: x[0]):
|
||||
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
|
||||
print(f"{did:<50} {count:>7} {py_path}")
|
||||
|
||||
elif len(args) == 2:
|
||||
device_id = args[0]
|
||||
output_dir = args[1]
|
||||
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
|
||||
written = extract_actions(data, device_id, output_dir)
|
||||
if written:
|
||||
print(f"\n共写入 {len(written)} 个 action 文件")
|
||||
|
||||
else:
|
||||
print("用法:")
|
||||
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
|
||||
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
69
.cursor/skills/create-device-skill/scripts/gen_auth.py
Normal file
69
.cursor/skills/create-device-skill/scripts/gen_auth.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 ak/sk 生成 UniLab API Authorization header。
|
||||
|
||||
算法: base64(ak:sk) → "Authorization: Lab <token>"
|
||||
|
||||
用法:
|
||||
python gen_auth.py <ak> <sk>
|
||||
python gen_auth.py --config <config.py>
|
||||
|
||||
示例:
|
||||
python gen_auth.py myak mysk
|
||||
python gen_auth.py --config experiments/config.py
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def gen_auth(ak: str, sk: str) -> str:
|
||||
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
|
||||
return token
|
||||
|
||||
|
||||
def extract_from_config(config_path: str) -> tuple:
|
||||
"""从 config.py 中提取 ak 和 sk"""
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
|
||||
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
|
||||
if not ak_match or not sk_match:
|
||||
return None, None
|
||||
return ak_match.group(1), sk_match.group(1)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 2 and args[0] == "--config":
|
||||
ak, sk = extract_from_config(args[1])
|
||||
if not ak or not sk:
|
||||
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
|
||||
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
|
||||
sys.exit(1)
|
||||
print(f"配置文件: {args[1]}")
|
||||
elif len(args) == 2:
|
||||
ak, sk = args
|
||||
else:
|
||||
print("用法:")
|
||||
print(" python gen_auth.py <ak> <sk>")
|
||||
print(" python gen_auth.py --config <config.py>")
|
||||
sys.exit(1)
|
||||
|
||||
token = gen_auth(ak, sk)
|
||||
print(f"ak: {ak}")
|
||||
print(f"sk: {sk}")
|
||||
print()
|
||||
print(f"Authorization header:")
|
||||
print(f" Authorization: Lab {token}")
|
||||
print()
|
||||
print(f"curl 用法:")
|
||||
print(f' curl -H "Authorization: Lab {token}" ...')
|
||||
print()
|
||||
print(f"Shell 变量:")
|
||||
print(f' AUTH="Authorization: Lab {token}"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
188
.cursorignore
188
.cursorignore
@@ -1,188 +0,0 @@
|
||||
# ============================================================
|
||||
# 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-*
|
||||
2
.github/workflows/ci-check.yml
vendored
2
.github/workflows/ci-check.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
||||
uv pip install .
|
||||
|
||||
- name: Run check mode (complete_registry)
|
||||
- name: Run check mode (AST registry validation)
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Running check mode...
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
.cursorignore
|
||||
device_package*/
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
87
AGENTS.md
Normal file
87
AGENTS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Also follow the monorepo-level rules in `../AGENTS.md`.
|
||||
|
||||
## Build & Development
|
||||
|
||||
```bash
|
||||
# Install in editable mode (requires mamba env with python 3.11)
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Run with a device graph
|
||||
unilab --graph <graph.json> --config <config.py> --backend ros
|
||||
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
||||
|
||||
# Common CLI flags
|
||||
unilab --app_bridges websocket fastapi # communication bridges
|
||||
unilab --test_mode # simulate hardware, no real execution
|
||||
unilab --check_mode # CI validation of registry imports
|
||||
unilab --skip_env_check # skip auto-install of dependencies
|
||||
unilab --visual rviz|web|disable # visualization mode
|
||||
unilab --is_slave # run as slave node
|
||||
|
||||
# Workflow upload subcommand
|
||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
||||
|
||||
# Tests
|
||||
pytest tests/ # all tests
|
||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
||||
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Startup Flow
|
||||
|
||||
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
||||
|
||||
### Core Layers
|
||||
|
||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
|
||||
|
||||
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
|
||||
|
||||
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
|
||||
|
||||
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
|
||||
|
||||
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
|
||||
|
||||
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
|
||||
|
||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
|
||||
|
||||
### Configuration System
|
||||
|
||||
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
|
||||
- Config files are `.py` files with matching class names (see `config/example_config.py`)
|
||||
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
||||
- Device topology defined in graph files (JSON with node-link format, or GraphML)
|
||||
|
||||
### Key Data Flow
|
||||
|
||||
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
||||
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
||||
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
||||
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
||||
|
||||
### Test Data
|
||||
|
||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Code comments and log messages in simplified Chinese
|
||||
- Python 3.11+, type hints expected
|
||||
- Pydantic models for data validation (`resource_tracker.py`)
|
||||
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
||||
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
||||
- CLI argument dashes auto-converted to underscores for consistency
|
||||
|
||||
## Licensing
|
||||
|
||||
- Framework code: GPL-3.0
|
||||
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
||||
@@ -15,6 +15,9 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
||||
class MockGripper:
|
||||
def __init__(self):
|
||||
self._position: float = 0.0
|
||||
@@ -23,19 +26,23 @@ class MockGripper:
|
||||
self._status = "Idle"
|
||||
|
||||
@property
|
||||
@topic_config() # 添加 @topic_config 才会定时广播
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def velocity(self) -> float:
|
||||
return self._velocity
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def torque(self) -> float:
|
||||
return self._torque
|
||||
|
||||
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -149,7 +156,7 @@ my_device: # 设备唯一标识符
|
||||
|
||||
系统会自动分析您的 Python 驱动类并生成:
|
||||
|
||||
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
||||
- `action_value_mappings`:从类方法自动生成动作映射
|
||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||
- `schema`:前端显示用的属性类型定义
|
||||
@@ -179,7 +186,9 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
||||
|
||||
```python
|
||||
from typing import Dict, Any
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="my_device", category=["general"], description="My Device")
|
||||
class MyDevice:
|
||||
"""设备类文档字符串
|
||||
|
||||
@@ -198,8 +207,9 @@ class MyDevice:
|
||||
# 初始化硬件连接
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加 @topic_config 才会广播
|
||||
def status(self) -> str:
|
||||
"""设备状态(会自动广播)"""
|
||||
"""设备状态(通过 @topic_config 广播)"""
|
||||
return self._status
|
||||
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
@@ -217,34 +227,61 @@ class MyDevice:
|
||||
|
||||
## 状态属性 vs 动作方法
|
||||
|
||||
### 状态属性(@property)
|
||||
### 状态属性(@property + @topic_config)
|
||||
|
||||
状态属性会被自动识别并定期广播:
|
||||
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加,否则不会广播
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._read_temperature()
|
||||
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
||||
def status(self) -> str:
|
||||
"""设备状态: idle, running, error"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config(name="ready") # 可自定义发布名称
|
||||
def is_ready(self) -> bool:
|
||||
"""设备是否就绪"""
|
||||
return self._status == "idle"
|
||||
```
|
||||
|
||||
也可以使用普通方法(非 @property)配合 `@topic_config`:
|
||||
|
||||
```python
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
"""获取传感器数据(get_ 前缀会自动去除,发布名为 sensor_data)"""
|
||||
return {"temp": self._temp, "humidity": self._humidity}
|
||||
```
|
||||
|
||||
**`@topic_config` 参数**:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `period` | float | 5.0 | 发布周期(秒) |
|
||||
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
|
||||
| `qos` | int | 10 | QoS 深度 |
|
||||
| `name` | str | None | 自定义发布名称 |
|
||||
|
||||
**发布名称优先级**:`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
|
||||
|
||||
**特点**:
|
||||
|
||||
- 使用`@property`装饰器
|
||||
- 只读,不能有参数
|
||||
- 自动添加到注册表的`status_types`
|
||||
- 必须使用 `@topic_config` 装饰器
|
||||
- 支持 `@property` 和普通方法
|
||||
- 添加到注册表的 `status_types`
|
||||
- 定期发布到 ROS2 topic
|
||||
|
||||
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
||||
|
||||
### 动作方法
|
||||
|
||||
动作方法是设备可以执行的操作:
|
||||
@@ -497,6 +534,7 @@ class LiquidHandler:
|
||||
self._status = "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -886,7 +924,52 @@ class MyDevice:
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 类型注解
|
||||
### 1. 使用 `@device` 装饰器标识设备类
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
- `id`:设备唯一标识符,用于注册表匹配
|
||||
- `category`:分类列表,前端用于分组显示
|
||||
- `description`:设备描述
|
||||
- `icon`:图标文件名(可选)
|
||||
|
||||
### 2. 使用 `@topic_config` 声明需要广播的状态
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ @property + @topic_config → 会广播
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✓ 普通方法 + @topic_config → 会广播(get_ 前缀自动去除)
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
return {"temp": self._temp}
|
||||
|
||||
# ✓ 使用 name 参数自定义发布名称
|
||||
@property
|
||||
@topic_config(name="ready")
|
||||
def is_ready(self) -> bool:
|
||||
return self._status == "idle"
|
||||
|
||||
# ✗ 仅有 @property,没有 @topic_config → 不会广播
|
||||
@property
|
||||
def internal_state(self) -> str:
|
||||
return self._state
|
||||
```
|
||||
|
||||
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
|
||||
|
||||
### 3. 类型注解
|
||||
|
||||
```python
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -901,7 +984,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. 文档字符串
|
||||
### 4. 文档字符串
|
||||
|
||||
```python
|
||||
def method(self, param: float) -> Dict[str, Any]:
|
||||
@@ -923,7 +1006,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
||||
pass
|
||||
```
|
||||
|
||||
### 3. 配置验证
|
||||
### 5. 配置验证
|
||||
|
||||
```python
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
@@ -937,7 +1020,7 @@ def __init__(self, config: Dict[str, Any]):
|
||||
self.baudrate = config['baudrate']
|
||||
```
|
||||
|
||||
### 4. 资源清理
|
||||
### 6. 资源清理
|
||||
|
||||
```python
|
||||
def __del__(self):
|
||||
@@ -946,7 +1029,7 @@ def __del__(self):
|
||||
self.connection.close()
|
||||
```
|
||||
|
||||
### 5. 设计前端友好的返回值
|
||||
### 7. 设计前端友好的返回值
|
||||
|
||||
**记住:返回值会直接显示在 Web 界面**
|
||||
|
||||
|
||||
@@ -422,18 +422,20 @@ placeholder_keys:
|
||||
|
||||
### status_types
|
||||
|
||||
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
||||
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
||||
|
||||
```yaml
|
||||
status_types:
|
||||
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
||||
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
||||
status: str # 从 get_status() 或 @property status
|
||||
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
||||
is_heating: bool
|
||||
status: str
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
|
||||
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
||||
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
||||
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
||||
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||
|
||||
@@ -537,11 +539,13 @@ class AdvancedLiquidHandler:
|
||||
self._temperature = 25.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
"""设备状态"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._temperature
|
||||
@@ -809,21 +813,23 @@ my_temperature_controller:
|
||||
你的设备类需要符合以下要求:
|
||||
|
||||
```python
|
||||
from unilabos.common.device_base import DeviceBase
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
class MyDevice(DeviceBase):
|
||||
@device(id="my_device", category=["temperature"], description="My Device")
|
||||
class MyDevice:
|
||||
def __init__(self, config):
|
||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||
super().__init__(config)
|
||||
self.port = config.get('port', '/dev/ttyUSB0')
|
||||
|
||||
# 状态方法(会自动生成到 status_types)
|
||||
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self):
|
||||
"""返回设备状态"""
|
||||
return "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self):
|
||||
"""返回当前温度"""
|
||||
return 25.0
|
||||
@@ -1039,7 +1045,34 @@ resource.type # "resource"
|
||||
|
||||
### 代码规范
|
||||
|
||||
1. **始终使用类型注解**
|
||||
1. **使用 `@device` 装饰器标识设备类**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Device")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
2. **使用 `@topic_config` 声明广播属性**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ 需要广播的状态属性
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✗ 仅有 @property 不会广播
|
||||
@property
|
||||
def internal_counter(self) -> int:
|
||||
return self._counter
|
||||
```
|
||||
|
||||
3. **始终使用类型注解**
|
||||
|
||||
```python
|
||||
# ✓ 好
|
||||
@@ -1051,7 +1084,7 @@ def method(self, resource, device):
|
||||
pass
|
||||
```
|
||||
|
||||
2. **提供有意义的参数名**
|
||||
4. **提供有意义的参数名**
|
||||
|
||||
```python
|
||||
# ✓ 好 - 清晰的参数名
|
||||
@@ -1063,7 +1096,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
||||
pass
|
||||
```
|
||||
|
||||
3. **使用 Optional 表示可选参数**
|
||||
5. **使用 Optional 表示可选参数**
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
@@ -1076,7 +1109,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
4. **添加详细的文档字符串**
|
||||
6. **添加详细的文档字符串**
|
||||
|
||||
```python
|
||||
def method(
|
||||
@@ -1096,13 +1129,13 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
5. **方法命名规范**
|
||||
7. **方法命名规范**
|
||||
|
||||
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
||||
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
||||
- 动作方法使用动词开头
|
||||
- 保持命名清晰、一致
|
||||
|
||||
6. **完善的错误处理**
|
||||
8. **完善的错误处理**
|
||||
- 实现完善的错误处理
|
||||
- 添加日志记录
|
||||
- 提供有意义的错误信息
|
||||
|
||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
||||
|
||||
```bash
|
||||
# 实验室A
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
|
||||
# 实验室B
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +22,6 @@ options:
|
||||
--is_slave Run the backend as slave node (without host privileges).
|
||||
--slave_no_host Skip waiting for host service in slave mode
|
||||
--upload_registry Upload registry information when starting unilab
|
||||
--use_remote_resource Use remote resources when starting unilab
|
||||
--config CONFIG Configuration file path, supports .py format Python config files
|
||||
--port PORT Port for web service information page
|
||||
--disable_browser Disable opening information page on startup
|
||||
@@ -85,7 +84,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
支持两种方式:
|
||||
|
||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||
- **远程资源**:不指定本地文件即可
|
||||
|
||||
### 7. 注册表构建
|
||||
|
||||
@@ -196,7 +195,7 @@ unilab --config path/to/your/config.py
|
||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||
|
||||
# 使用远程资源启动
|
||||
unilab --ak your_ak --sk your_sk --use_remote_resource
|
||||
unilab --ak your_ak --sk your_sk
|
||||
|
||||
# 更新注册表
|
||||
unilab --ak your_ak --sk your_sk --complete_registry
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.18
|
||||
version: 0.10.19
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.18"
|
||||
version: "0.10.19"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.18',
|
||||
version='0.10.19',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
0
tests/compile/__init__.py
Normal file
0
tests/compile/__init__.py
Normal file
296
tests/compile/test_batch_transfer_protocol.py
Normal file
296
tests/compile/test_batch_transfer_protocol.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
批量转运编译器测试
|
||||
|
||||
覆盖:单物料退化、刚好一批、多批次、空操作、AGV 配置发现、children dict 状态。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.compile.batch_transfer_protocol import generate_batch_transfer_protocol
|
||||
from unilabos.compile.agv_transfer_protocol import generate_agv_transfer_protocol
|
||||
from unilabos.compile._agv_utils import find_agv_config, get_agv_capacity, split_batches
|
||||
|
||||
|
||||
# ============ 构建测试用设备图 ============
|
||||
|
||||
def _make_graph(capacity_x=2, capacity_y=1, capacity_z=1):
|
||||
"""构建包含 AGV 节点的测试设备图"""
|
||||
G = nx.DiGraph()
|
||||
|
||||
# AGV 节点
|
||||
G.add_node("AGV", **{
|
||||
"type": "device",
|
||||
"class_": "agv_transport_station",
|
||||
"config": {
|
||||
"protocol_type": ["AGVTransferProtocol", "BatchTransferProtocol"],
|
||||
"device_roles": {
|
||||
"navigator": "zhixing_agv",
|
||||
"arm": "zhixing_ur_arm"
|
||||
},
|
||||
"route_table": {
|
||||
"StationA->StationB": {
|
||||
"nav_command": '{"target": "LM1"}',
|
||||
"arm_pick": '{"task_name": "pick.urp"}',
|
||||
"arm_place": '{"task_name": "place.urp"}'
|
||||
},
|
||||
"AGV->StationA": {
|
||||
"nav_command": '{"target": "LM1"}',
|
||||
"arm_pick": '{"task_name": "pick.urp"}',
|
||||
"arm_place": '{"task_name": "place.urp"}'
|
||||
},
|
||||
"StationA->StationA": {
|
||||
"nav_command": '{"target": "LM1"}',
|
||||
"arm_pick": '{"task_name": "pick.urp"}',
|
||||
"arm_place": '{"task_name": "place.urp"}'
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# AGV 子设备
|
||||
G.add_node("zhixing_agv", type="device", class_="zhixing_agv")
|
||||
G.add_node("zhixing_ur_arm", type="device", class_="zhixing_ur_arm")
|
||||
G.add_edge("AGV", "zhixing_agv")
|
||||
G.add_edge("AGV", "zhixing_ur_arm")
|
||||
|
||||
# AGV Warehouse 子资源
|
||||
G.add_node("agv_platform", **{
|
||||
"type": "warehouse",
|
||||
"config": {
|
||||
"name": "agv_platform",
|
||||
"num_items_x": capacity_x,
|
||||
"num_items_y": capacity_y,
|
||||
"num_items_z": capacity_z,
|
||||
}
|
||||
})
|
||||
G.add_edge("AGV", "agv_platform")
|
||||
|
||||
# 来源/目标工站
|
||||
G.add_node("StationA", type="device", class_="workstation")
|
||||
G.add_node("StationB", type="device", class_="workstation")
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def _make_repos(items_count=2):
|
||||
"""构建测试用的 from_repo 和 to_repo dict"""
|
||||
children = {}
|
||||
for i in range(items_count):
|
||||
pos = f"A{i + 1:02d}"
|
||||
children[pos] = {
|
||||
"id": f"resource_{i + 1}",
|
||||
"name": f"R{i + 1}",
|
||||
"parent": "StationA",
|
||||
"type": "resource",
|
||||
}
|
||||
|
||||
from_repo = {
|
||||
"StationA": {
|
||||
"id": "StationA",
|
||||
"name": "StationA",
|
||||
"children": children,
|
||||
}
|
||||
}
|
||||
to_repo = {
|
||||
"StationB": {
|
||||
"id": "StationB",
|
||||
"name": "StationB",
|
||||
"children": {},
|
||||
}
|
||||
}
|
||||
return from_repo, to_repo
|
||||
|
||||
|
||||
def _make_items(count=2):
|
||||
"""构建 transfer_resources / from_positions / to_positions"""
|
||||
resources = [
|
||||
{
|
||||
"id": f"resource_{i + 1}",
|
||||
"name": f"R{i + 1}",
|
||||
"sample_id": f"uuid-{i + 1}",
|
||||
"parent": "StationA",
|
||||
"type": "resource",
|
||||
}
|
||||
for i in range(count)
|
||||
]
|
||||
from_positions = [f"A{i + 1:02d}" for i in range(count)]
|
||||
to_positions = [f"A{i + 1:02d}" for i in range(count)]
|
||||
return resources, from_positions, to_positions
|
||||
|
||||
|
||||
# ============ _agv_utils 测试 ============
|
||||
|
||||
class TestAGVUtils:
|
||||
def test_find_agv_config(self):
|
||||
G = _make_graph()
|
||||
cfg = find_agv_config(G)
|
||||
assert cfg["agv_id"] == "AGV"
|
||||
assert cfg["device_roles"]["navigator"] == "zhixing_agv"
|
||||
assert cfg["device_roles"]["arm"] == "zhixing_ur_arm"
|
||||
assert "StationA->StationB" in cfg["route_table"]
|
||||
|
||||
def test_find_agv_config_by_id(self):
|
||||
G = _make_graph()
|
||||
cfg = find_agv_config(G, agv_id="AGV")
|
||||
assert cfg["agv_id"] == "AGV"
|
||||
|
||||
def test_find_agv_config_not_found(self):
|
||||
G = nx.DiGraph()
|
||||
G.add_node("SomeDevice", type="device", class_="pump")
|
||||
with pytest.raises(ValueError, match="未找到 AGV"):
|
||||
find_agv_config(G)
|
||||
|
||||
def test_get_agv_capacity(self):
|
||||
G = _make_graph(capacity_x=2, capacity_y=1, capacity_z=1)
|
||||
assert get_agv_capacity(G, "AGV") == 2
|
||||
|
||||
def test_get_agv_capacity_multi_layer(self):
|
||||
G = _make_graph(capacity_x=1, capacity_y=2, capacity_z=3)
|
||||
assert get_agv_capacity(G, "AGV") == 6
|
||||
|
||||
def test_split_batches_exact(self):
|
||||
assert split_batches([1, 2], 2) == [[1, 2]]
|
||||
|
||||
def test_split_batches_overflow(self):
|
||||
assert split_batches([1, 2, 3], 2) == [[1, 2], [3]]
|
||||
|
||||
def test_split_batches_single(self):
|
||||
assert split_batches([1], 4) == [[1]]
|
||||
|
||||
def test_split_batches_zero_capacity(self):
|
||||
with pytest.raises(ValueError):
|
||||
split_batches([1], 0)
|
||||
|
||||
|
||||
# ============ 批量转运编译器测试 ============
|
||||
|
||||
class TestBatchTransferProtocol:
|
||||
def test_empty_items(self):
|
||||
"""空物料列表返回空 steps"""
|
||||
G = _make_graph()
|
||||
from_repo, to_repo = _make_repos(0)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, [], [], [])
|
||||
assert steps == []
|
||||
|
||||
def test_single_item(self):
|
||||
"""单物料转运(BatchTransfer 退化为单物料)"""
|
||||
G = _make_graph(capacity_x=2)
|
||||
from_repo, to_repo = _make_repos(1)
|
||||
resources, from_pos, to_pos = _make_items(1)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
# 应该有: nav到来源 + 1个pick + nav到目标 + 1个place = 4 steps
|
||||
assert len(steps) == 4
|
||||
assert steps[0]["action_name"] == "send_nav_task"
|
||||
assert steps[1]["action_name"] == "move_pos_task"
|
||||
assert steps[1]["_transfer_meta"]["phase"] == "pick"
|
||||
assert steps[2]["action_name"] == "send_nav_task"
|
||||
assert steps[3]["action_name"] == "move_pos_task"
|
||||
assert steps[3]["_transfer_meta"]["phase"] == "place"
|
||||
|
||||
def test_exact_capacity(self):
|
||||
"""物料数 = AGV 容量,刚好一批"""
|
||||
G = _make_graph(capacity_x=2)
|
||||
from_repo, to_repo = _make_repos(2)
|
||||
resources, from_pos, to_pos = _make_items(2)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
# nav + 2 pick + nav + 2 place = 6 steps
|
||||
assert len(steps) == 6
|
||||
pick_steps = [s for s in steps if s.get("_transfer_meta", {}).get("phase") == "pick"]
|
||||
place_steps = [s for s in steps if s.get("_transfer_meta", {}).get("phase") == "place"]
|
||||
assert len(pick_steps) == 2
|
||||
assert len(place_steps) == 2
|
||||
|
||||
def test_multi_batch(self):
|
||||
"""物料数 > AGV 容量,自动分批"""
|
||||
G = _make_graph(capacity_x=2)
|
||||
from_repo, to_repo = _make_repos(3)
|
||||
resources, from_pos, to_pos = _make_items(3)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
# 批次1: nav + 2 pick + nav + 2 place + nav(返回) = 7
|
||||
# 批次2: nav + 1 pick + nav + 1 place = 4
|
||||
# 总计 11 steps
|
||||
assert len(steps) == 11
|
||||
|
||||
nav_steps = [s for s in steps if s["action_name"] == "send_nav_task"]
|
||||
# 批次1: 2 nav(去来源+去目标) + 1 nav(返回) + 批次2: 2 nav = 5 nav
|
||||
assert len(nav_steps) == 5
|
||||
|
||||
def test_children_dict_updated(self):
|
||||
"""compile 阶段三方 children dict 状态正确"""
|
||||
G = _make_graph(capacity_x=2)
|
||||
from_repo, to_repo = _make_repos(2)
|
||||
resources, from_pos, to_pos = _make_items(2)
|
||||
|
||||
assert "A01" in from_repo["StationA"]["children"]
|
||||
assert "A02" in from_repo["StationA"]["children"]
|
||||
assert len(to_repo["StationB"]["children"]) == 0
|
||||
|
||||
generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
# compile 后 from_repo 的 children 应该被 pop 掉
|
||||
assert "A01" not in from_repo["StationA"]["children"]
|
||||
assert "A02" not in from_repo["StationA"]["children"]
|
||||
# to_repo 应该有新物料
|
||||
assert "A01" in to_repo["StationB"]["children"]
|
||||
assert "A02" in to_repo["StationB"]["children"]
|
||||
assert to_repo["StationB"]["children"]["A01"]["id"] == "resource_1"
|
||||
|
||||
def test_device_ids_from_config(self):
|
||||
"""设备 ID 全部从配置读取,不硬编码"""
|
||||
G = _make_graph()
|
||||
from_repo, to_repo = _make_repos(1)
|
||||
resources, from_pos, to_pos = _make_items(1)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
device_ids = {s["device_id"] for s in steps}
|
||||
assert "zhixing_agv" in device_ids
|
||||
assert "zhixing_ur_arm" in device_ids
|
||||
|
||||
def test_route_not_found(self):
|
||||
"""路由表中无对应路线时报错"""
|
||||
G = _make_graph()
|
||||
from_repo = {"Unknown": {"id": "Unknown", "children": {"A01": {"id": "R1", "parent": "Unknown"}}}}
|
||||
to_repo = {"Other": {"id": "Other", "children": {}}}
|
||||
resources = [{"id": "R1", "name": "R1"}]
|
||||
with pytest.raises(KeyError, match="路由表"):
|
||||
generate_batch_transfer_protocol(G, from_repo, to_repo, resources, ["A01"], ["B01"])
|
||||
|
||||
def test_length_mismatch(self):
|
||||
"""三个数组长度不一致时报错"""
|
||||
G = _make_graph()
|
||||
from_repo, to_repo = _make_repos(2)
|
||||
resources = [{"id": "R1"}]
|
||||
with pytest.raises(ValueError, match="长度不一致"):
|
||||
generate_batch_transfer_protocol(G, from_repo, to_repo, resources, ["A01", "A02"], ["B01"])
|
||||
|
||||
|
||||
# ============ 改造后的 AGV 单物料编译器测试 ============
|
||||
|
||||
class TestAGVTransferProtocol:
|
||||
def test_single_transfer_from_config(self):
|
||||
"""改造后的单物料编译器从 G 读取配置"""
|
||||
G = _make_graph()
|
||||
from_repo = {"StationA": {"id": "StationA", "children": {"A01": {"id": "R1", "parent": "StationA"}}}}
|
||||
to_repo = {"StationB": {"id": "StationB", "children": {}}}
|
||||
steps = generate_agv_transfer_protocol(G, from_repo, "A01", to_repo, "B01")
|
||||
|
||||
assert len(steps) == 2
|
||||
assert steps[0]["device_id"] == "zhixing_agv"
|
||||
assert steps[0]["action_name"] == "send_nav_task"
|
||||
assert steps[1]["device_id"] == "zhixing_ur_arm"
|
||||
assert steps[1]["action_name"] == "move_pos_task"
|
||||
|
||||
def test_children_updated(self):
|
||||
"""单物料编译后 children dict 正确更新"""
|
||||
G = _make_graph()
|
||||
from_repo = {"StationA": {"id": "StationA", "children": {"A01": {"id": "R1", "parent": "StationA"}}}}
|
||||
to_repo = {"StationB": {"id": "StationB", "children": {}}}
|
||||
generate_agv_transfer_protocol(G, from_repo, "A01", to_repo, "B01")
|
||||
|
||||
assert "A01" not in from_repo["StationA"]["children"]
|
||||
assert "B01" in to_repo["StationB"]["children"]
|
||||
assert to_repo["StationB"]["children"]["B01"]["parent"] == "StationB"
|
||||
706
tests/compile/test_full_chain_conversion_to_compile.py
Normal file
706
tests/compile/test_full_chain_conversion_to_compile.py
Normal file
@@ -0,0 +1,706 @@
|
||||
"""
|
||||
全链路集成测试:ROS Goal 转换 → ResourceTreeSet → get_plr_nested_dict → 编译器 → 动作列表
|
||||
|
||||
模拟 workstation.py 中的完整路径:
|
||||
1. host 返回 raw_data(模拟 resource_get 响应)
|
||||
2. ResourceTreeSet.from_raw_dict_list(raw_data) 构建资源树
|
||||
3. tree.root_node.get_plr_nested_dict() 生成嵌套 dict
|
||||
4. protocol_kwargs 传给编译器
|
||||
5. 编译器返回 action_list,验证结构和关键字段
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import pytest
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
)
|
||||
from unilabos.compile.utils.resource_helper import (
|
||||
ensure_resource_instance,
|
||||
resource_to_dict,
|
||||
get_resource_id,
|
||||
get_resource_data,
|
||||
)
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
# ============ 构建模拟设备图 ============
|
||||
|
||||
def _build_test_graph():
|
||||
"""构建一个包含常用设备节点的测试图"""
|
||||
G = nx.DiGraph()
|
||||
|
||||
# 容器
|
||||
G.add_node("reactor_01", **{
|
||||
"id": "reactor_01",
|
||||
"name": "reactor_01",
|
||||
"type": "device",
|
||||
"class": "virtual_stirrer",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
|
||||
# 搅拌设备
|
||||
G.add_node("stirrer_1", **{
|
||||
"id": "stirrer_1",
|
||||
"name": "stirrer_1",
|
||||
"type": "device",
|
||||
"class": "virtual_stirrer",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
G.add_edge("stirrer_1", "reactor_01")
|
||||
|
||||
# 加热设备
|
||||
G.add_node("heatchill_1", **{
|
||||
"id": "heatchill_1",
|
||||
"name": "heatchill_1",
|
||||
"type": "device",
|
||||
"class": "virtual_heatchill",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
G.add_edge("heatchill_1", "reactor_01")
|
||||
|
||||
# 试剂容器(液体)
|
||||
G.add_node("flask_water", **{
|
||||
"id": "flask_water",
|
||||
"name": "flask_water",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"data": {"reagent_name": "water", "liquid": [{"liquid_type": "water", "volume": 500.0}]},
|
||||
"config": {"reagent": "water"},
|
||||
})
|
||||
|
||||
# 固体加样器
|
||||
G.add_node("solid_dispenser_1", **{
|
||||
"id": "solid_dispenser_1",
|
||||
"name": "solid_dispenser_1",
|
||||
"type": "device",
|
||||
"class": "solid_dispenser",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
|
||||
# 泵
|
||||
G.add_node("pump_1", **{
|
||||
"id": "pump_1",
|
||||
"name": "pump_1",
|
||||
"type": "device",
|
||||
"class": "virtual_pump",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
G.add_edge("flask_water", "pump_1")
|
||||
G.add_edge("pump_1", "reactor_01")
|
||||
|
||||
return G
|
||||
|
||||
|
||||
# ============ 构建模拟 host 返回数据 ============
|
||||
|
||||
def _make_raw_resource(
|
||||
id="reactor_01",
|
||||
uuid="uuid-reactor-01",
|
||||
name="reactor_01",
|
||||
klass="virtual_stirrer",
|
||||
type_="device",
|
||||
parent=None,
|
||||
parent_uuid=None,
|
||||
data=None,
|
||||
config=None,
|
||||
extra=None,
|
||||
):
|
||||
"""模拟 host 返回的单个资源 dict(与 resource_get 服务响应一致)"""
|
||||
return {
|
||||
"id": id,
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"class": klass,
|
||||
"type": type_,
|
||||
"parent": parent,
|
||||
"parent_uuid": parent_uuid or "",
|
||||
"description": "",
|
||||
"config": config or {},
|
||||
"data": data or {},
|
||||
"extra": extra or {},
|
||||
"position": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
}
|
||||
|
||||
|
||||
def _simulate_workstation_resource_enrichment(raw_data_list, field_type="unilabos_msgs/Resource"):
|
||||
"""
|
||||
模拟 workstation.py 中 resource enrichment 的核心逻辑:
|
||||
raw_data → ResourceTreeSet.from_raw_dict_list → get_plr_nested_dict → protocol_kwargs[k]
|
||||
"""
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data_list)
|
||||
|
||||
if field_type == "unilabos_msgs/Resource":
|
||||
# 单个 Resource:取第一棵树的根节点
|
||||
root_instance = tree_set.trees[0].root_node if tree_set.trees else None
|
||||
return root_instance.get_plr_nested_dict() if root_instance else {}
|
||||
else:
|
||||
# sequence<Resource>:返回列表
|
||||
return [tree.root_node.get_plr_nested_dict() for tree in tree_set.trees]
|
||||
|
||||
|
||||
# ============ 全链路测试:Stir 协议 ============
|
||||
|
||||
class TestStirProtocolFullChain:
|
||||
"""Stir 协议全链路:host raw_data → enriched dict → compiler → action_list"""
|
||||
|
||||
def test_stir_with_enriched_resource_dict(self):
|
||||
"""单个 Resource 经过 enrichment 后传给 stir compiler"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", uuid="uuid-reactor-01",
|
||||
klass="virtual_stirrer", type_="device",
|
||||
)]
|
||||
|
||||
# 模拟 workstation enrichment
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
assert enriched_vessel["id"] == "reactor_01"
|
||||
assert enriched_vessel["uuid"] == "uuid-reactor-01"
|
||||
assert enriched_vessel["class"] == "virtual_stirrer"
|
||||
|
||||
# 传给编译器
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
time="60",
|
||||
stir_speed=300.0,
|
||||
)
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) >= 1
|
||||
action = actions[0]
|
||||
assert action["device_id"] == "stirrer_1"
|
||||
assert action["action_name"] == "stir"
|
||||
assert "vessel" in action["action_kwargs"]
|
||||
assert action["action_kwargs"]["vessel"]["id"] == "reactor_01"
|
||||
|
||||
def test_stir_with_resource_dict_instance(self):
|
||||
"""直接用 ResourceDictInstance 传给 stir compiler(通过 get_plr_nested_dict 转换)"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
inst = tree_set.trees[0].root_node
|
||||
|
||||
# 通过 resource_to_dict 转换(resource_helper 兼容层)
|
||||
vessel_dict = resource_to_dict(inst)
|
||||
assert isinstance(vessel_dict, dict)
|
||||
assert vessel_dict["id"] == "reactor_01"
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(G=G, vessel=vessel_dict, time="30")
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["action_name"] == "stir"
|
||||
|
||||
def test_stir_with_string_vessel(self):
|
||||
"""兼容旧模式:直接传 vessel 字符串"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(G=G, vessel="reactor_01", time="30")
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["device_id"] == "stirrer_1"
|
||||
assert actions[0]["action_kwargs"]["vessel"]["id"] == "reactor_01"
|
||||
|
||||
|
||||
# ============ 全链路测试:HeatChill 协议 ============
|
||||
|
||||
class TestHeatChillProtocolFullChain:
|
||||
"""HeatChill 协议全链路"""
|
||||
|
||||
def test_heatchill_with_enriched_resource(self):
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01", klass="virtual_stirrer")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_heat_chill_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
temp=80.0,
|
||||
time="300",
|
||||
)
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) >= 1
|
||||
action = actions[0]
|
||||
assert action["device_id"] == "heatchill_1"
|
||||
assert action["action_name"] == "heat_chill"
|
||||
assert action["action_kwargs"]["temp"] == 80.0
|
||||
|
||||
def test_heatchill_start_with_enriched_resource(self):
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_start_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_heat_chill_start_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
temp=60.0,
|
||||
)
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["action_name"] == "heat_chill_start"
|
||||
assert actions[0]["action_kwargs"]["temp"] == 60.0
|
||||
|
||||
def test_heatchill_stop_with_enriched_resource(self):
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_stop_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_heat_chill_stop_protocol(G=G, vessel=enriched_vessel)
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["action_name"] == "heat_chill_stop"
|
||||
|
||||
|
||||
# ============ 全链路测试:Add 协议 ============
|
||||
|
||||
class TestAddProtocolFullChain:
|
||||
"""Add 协议全链路:vessel enrichment + reagent 查找 + 泵传输"""
|
||||
|
||||
def test_add_solid_with_enriched_resource(self):
|
||||
from unilabos.compile.add_protocol import generate_add_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_add_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
reagent="NaCl",
|
||||
mass="5 g",
|
||||
)
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) >= 1
|
||||
# 应该包含至少一个 add_solid 或 log_message 动作
|
||||
action_names = [a.get("action_name", "") for a in actions]
|
||||
assert any(name in ["add_solid", "log_message"] for name in action_names)
|
||||
|
||||
def test_add_liquid_with_enriched_resource(self):
|
||||
from unilabos.compile.add_protocol import generate_add_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_add_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
reagent="water",
|
||||
volume="10 mL",
|
||||
)
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) >= 1
|
||||
|
||||
|
||||
# ============ 全链路测试:ResourceDictInstance 兼容层 ============
|
||||
|
||||
class TestResourceDictInstanceCompatibility:
|
||||
"""验证编译器兼容层对 ResourceDictInstance 的处理"""
|
||||
|
||||
def test_get_vessel_from_enriched_dict(self):
|
||||
"""get_vessel 对 enriched dict 的处理"""
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01",
|
||||
data={"temperature": 25.0, "liquid": [{"liquid_type": "water", "volume": 10.0}]},
|
||||
)]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
vessel_id, vessel_data = get_vessel(enriched)
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["temperature"] == 25.0
|
||||
assert len(vessel_data["liquid"]) == 1
|
||||
|
||||
def test_get_vessel_from_resource_instance(self):
|
||||
"""get_vessel 直接对 ResourceDictInstance 的处理"""
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01",
|
||||
data={"temperature": 25.0},
|
||||
)]
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
inst = tree_set.trees[0].root_node
|
||||
|
||||
vessel_id, vessel_data = get_vessel(inst)
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["temperature"] == 25.0
|
||||
|
||||
def test_ensure_resource_instance_round_trip(self):
|
||||
"""ensure_resource_instance → resource_to_dict 无损往返"""
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", uuid="uuid-r01", klass="virtual_stirrer",
|
||||
data={"temp": 25.0},
|
||||
)]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
# dict → ResourceDictInstance
|
||||
inst = ensure_resource_instance(enriched)
|
||||
assert isinstance(inst, ResourceDictInstance)
|
||||
assert inst.res_content.id == "reactor_01"
|
||||
assert inst.res_content.uuid == "uuid-r01"
|
||||
|
||||
# ResourceDictInstance → dict
|
||||
d = resource_to_dict(inst)
|
||||
assert isinstance(d, dict)
|
||||
assert d["id"] == "reactor_01"
|
||||
assert d["uuid"] == "uuid-r01"
|
||||
assert d["class"] == "virtual_stirrer"
|
||||
|
||||
|
||||
# ============ 全链路测试:带 children 的资源树 ============
|
||||
|
||||
class TestResourceTreeWithChildren:
|
||||
"""测试带 children 结构的资源树通过编译器的路径"""
|
||||
|
||||
def _make_tree_with_children(self):
|
||||
"""构建 StationA -> [Flask1, Flask2] 的资源树"""
|
||||
return [
|
||||
_make_raw_resource(
|
||||
id="StationA", uuid="uuid-station-a",
|
||||
name="StationA", klass="workstation", type_="device",
|
||||
),
|
||||
_make_raw_resource(
|
||||
id="Flask1", uuid="uuid-flask-1",
|
||||
name="Flask1", klass="", type_="resource",
|
||||
parent="StationA", parent_uuid="uuid-station-a",
|
||||
data={"liquid": [{"liquid_type": "water", "volume": 10.0}]},
|
||||
),
|
||||
_make_raw_resource(
|
||||
id="Flask2", uuid="uuid-flask-2",
|
||||
name="Flask2", klass="", type_="resource",
|
||||
parent="StationA", parent_uuid="uuid-station-a",
|
||||
data={"liquid": [{"liquid_type": "ethanol", "volume": 5.0}]},
|
||||
),
|
||||
]
|
||||
|
||||
def test_enrichment_preserves_children_structure(self):
|
||||
"""验证 enrichment 后 children 为嵌套 dict"""
|
||||
raw_data = self._make_tree_with_children()
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
assert enriched["id"] == "StationA"
|
||||
assert "children" in enriched
|
||||
assert isinstance(enriched["children"], dict)
|
||||
assert "Flask1" in enriched["children"]
|
||||
assert "Flask2" in enriched["children"]
|
||||
|
||||
def test_children_preserve_uuid_and_data(self):
|
||||
"""验证 children 中的 uuid 和 data 被正确保留"""
|
||||
raw_data = self._make_tree_with_children()
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
flask1 = enriched["children"]["Flask1"]
|
||||
assert flask1["uuid"] == "uuid-flask-1"
|
||||
assert flask1["data"]["liquid"][0]["liquid_type"] == "water"
|
||||
assert flask1["data"]["liquid"][0]["volume"] == 10.0
|
||||
|
||||
flask2 = enriched["children"]["Flask2"]
|
||||
assert flask2["uuid"] == "uuid-flask-2"
|
||||
assert flask2["data"]["liquid"][0]["liquid_type"] == "ethanol"
|
||||
|
||||
def test_children_dict_can_be_popped(self):
|
||||
"""模拟 batch_transfer_protocol 中 pop children 的操作"""
|
||||
raw_data = self._make_tree_with_children()
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
# batch_transfer_protocol 中会 pop children
|
||||
children = enriched["children"]
|
||||
popped = children.pop("Flask1")
|
||||
assert popped["id"] == "Flask1"
|
||||
assert "Flask1" not in enriched["children"]
|
||||
assert "Flask2" in enriched["children"]
|
||||
|
||||
def test_children_dict_usable_as_from_repo(self):
|
||||
"""模拟 batch_transfer_protocol 中 from_repo 参数"""
|
||||
raw_data = self._make_tree_with_children()
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
# 模拟编译器接收的 from_repo 格式
|
||||
from_repo = {"StationA": enriched}
|
||||
from_repo_ = list(from_repo.values())[0]
|
||||
|
||||
assert from_repo_["id"] == "StationA"
|
||||
assert "Flask1" in from_repo_["children"]
|
||||
assert from_repo_["children"]["Flask1"]["uuid"] == "uuid-flask-1"
|
||||
|
||||
def test_sequence_resource_enrichment(self):
|
||||
"""sequence<Resource> 情况:多个独立资源树"""
|
||||
raw_data1 = [_make_raw_resource(id="R1", uuid="uuid-r1")]
|
||||
raw_data2 = [_make_raw_resource(id="R2", uuid="uuid-r2")]
|
||||
|
||||
tree_set1 = ResourceTreeSet.from_raw_dict_list(raw_data1)
|
||||
tree_set2 = ResourceTreeSet.from_raw_dict_list(raw_data2)
|
||||
|
||||
results = [
|
||||
tree.root_node.get_plr_nested_dict()
|
||||
for ts in [tree_set1, tree_set2]
|
||||
for tree in ts.trees
|
||||
]
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0]["id"] == "R1"
|
||||
assert results[1]["id"] == "R2"
|
||||
|
||||
|
||||
# ============ 全链路测试:动作列表结构验证 ============
|
||||
|
||||
class TestActionListStructure:
|
||||
"""验证编译器返回的 action_list 结构符合 workstation 预期"""
|
||||
|
||||
def _validate_action(self, action):
|
||||
"""验证单个 action dict 的结构"""
|
||||
if action.get("action_name") == "wait":
|
||||
# wait 伪动作不需要 device_id
|
||||
assert "action_kwargs" in action
|
||||
assert "time" in action["action_kwargs"]
|
||||
return
|
||||
|
||||
if action.get("action_name") == "log_message":
|
||||
# log 伪动作
|
||||
assert "action_kwargs" in action
|
||||
return
|
||||
|
||||
# 正常设备动作
|
||||
assert "device_id" in action, f"action 缺少 device_id: {action}"
|
||||
assert "action_name" in action, f"action 缺少 action_name: {action}"
|
||||
assert "action_kwargs" in action, f"action 缺少 action_kwargs: {action}"
|
||||
assert isinstance(action["action_kwargs"], dict)
|
||||
|
||||
def test_stir_action_list_structure(self):
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(G=G, vessel=enriched, time="60")
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, list):
|
||||
# 并行动作
|
||||
for sub_action in action:
|
||||
self._validate_action(sub_action)
|
||||
else:
|
||||
self._validate_action(action)
|
||||
|
||||
def test_heatchill_action_list_structure(self):
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_heat_chill_protocol(G=G, vessel=enriched, temp=80.0, time="60")
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, list):
|
||||
for sub_action in action:
|
||||
self._validate_action(sub_action)
|
||||
else:
|
||||
self._validate_action(action)
|
||||
|
||||
def test_add_action_list_structure(self):
|
||||
from unilabos.compile.add_protocol import generate_add_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_add_protocol(G=G, vessel=enriched, reagent="NaCl", mass="5 g")
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, list):
|
||||
for sub_action in action:
|
||||
self._validate_action(sub_action)
|
||||
else:
|
||||
self._validate_action(action)
|
||||
|
||||
|
||||
# ============ 全链路测试:message_converter 到 enrichment ============
|
||||
|
||||
class TestMessageConverterToEnrichment:
|
||||
"""模拟从 ROS 消息转换后的 dict 到 enrichment 的完整链路"""
|
||||
|
||||
def test_ros_goal_conversion_simulation(self):
|
||||
"""
|
||||
模拟 workstation.py 中的完整流程:
|
||||
1. ROS goal 中的 vessel 字段被 convert_from_ros_msg 转换为浅层 dict
|
||||
2. workstation 用 resource_id 请求 host 获取完整资源数据
|
||||
3. ResourceTreeSet.from_raw_dict_list 构建资源树
|
||||
4. get_plr_nested_dict 生成嵌套 dict 替换 protocol_kwargs[k]
|
||||
"""
|
||||
# 步骤1: 模拟 convert_from_ros_msg 的输出(浅层 dict,只有 id 等基本字段)
|
||||
shallow_vessel = {
|
||||
"id": "reactor_01",
|
||||
"uuid": "uuid-reactor-01",
|
||||
"name": "reactor_01",
|
||||
"type": "device",
|
||||
"category": "virtual_stirrer",
|
||||
"children": [],
|
||||
"parent": "",
|
||||
"parent_uuid": "",
|
||||
"config": {},
|
||||
"data": {},
|
||||
"extra": {},
|
||||
"position": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
}
|
||||
|
||||
protocol_kwargs = {
|
||||
"vessel": shallow_vessel,
|
||||
"time": "300",
|
||||
"stir_speed": 300.0,
|
||||
}
|
||||
|
||||
# 步骤2: 提取 resource_id
|
||||
resource_id = protocol_kwargs["vessel"]["id"]
|
||||
assert resource_id == "reactor_01"
|
||||
|
||||
# 步骤3: 模拟 host 返回完整数据(带 children)
|
||||
host_response = [
|
||||
_make_raw_resource(
|
||||
id="reactor_01", uuid="uuid-reactor-01",
|
||||
klass="virtual_stirrer", type_="device",
|
||||
data={"temperature": 25.0, "pressure": 1.0},
|
||||
config={"max_temp": 300.0},
|
||||
),
|
||||
]
|
||||
|
||||
# 步骤4: enrichment
|
||||
enriched = _simulate_workstation_resource_enrichment(host_response)
|
||||
protocol_kwargs["vessel"] = enriched
|
||||
|
||||
# 验证 enrichment 后的 protocol_kwargs
|
||||
assert protocol_kwargs["vessel"]["id"] == "reactor_01"
|
||||
assert protocol_kwargs["vessel"]["uuid"] == "uuid-reactor-01"
|
||||
assert protocol_kwargs["vessel"]["class"] == "virtual_stirrer"
|
||||
assert protocol_kwargs["vessel"]["data"]["temperature"] == 25.0
|
||||
assert protocol_kwargs["vessel"]["config"]["max_temp"] == 300.0
|
||||
|
||||
# 步骤5: 传给编译器
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(G=G, **protocol_kwargs)
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["device_id"] == "stirrer_1"
|
||||
assert actions[0]["action_name"] == "stir"
|
||||
|
||||
def test_ros_goal_with_children_enrichment(self):
|
||||
"""ROS goal → enrichment 带 children 的场景(batch transfer)"""
|
||||
# 模拟 host 返回带 children 的数据
|
||||
host_response = [
|
||||
_make_raw_resource(
|
||||
id="StationA", uuid="uuid-sa", klass="workstation", type_="device",
|
||||
config={"num_items_x": 4, "num_items_y": 2},
|
||||
),
|
||||
_make_raw_resource(
|
||||
id="Plate1", uuid="uuid-p1", type_="resource",
|
||||
parent="StationA", parent_uuid="uuid-sa",
|
||||
data={"sample": "sample_A"},
|
||||
),
|
||||
_make_raw_resource(
|
||||
id="Plate2", uuid="uuid-p2", type_="resource",
|
||||
parent="StationA", parent_uuid="uuid-sa",
|
||||
data={"sample": "sample_B"},
|
||||
),
|
||||
]
|
||||
|
||||
enriched = _simulate_workstation_resource_enrichment(host_response)
|
||||
|
||||
assert enriched["id"] == "StationA"
|
||||
assert enriched["class"] == "workstation"
|
||||
assert len(enriched["children"]) == 2
|
||||
assert enriched["children"]["Plate1"]["data"]["sample"] == "sample_A"
|
||||
assert enriched["children"]["Plate2"]["uuid"] == "uuid-p2"
|
||||
|
||||
# 模拟 batch_transfer 的 from_repo 格式
|
||||
from_repo = {"StationA": enriched}
|
||||
from_repo_ = list(from_repo.values())[0]
|
||||
assert "Plate1" in from_repo_["children"]
|
||||
assert from_repo_["children"]["Plate1"]["uuid"] == "uuid-p1"
|
||||
|
||||
|
||||
# ============ 全链路测试:多协议连续调用 ============
|
||||
|
||||
class TestMultiProtocolChain:
|
||||
"""模拟连续执行多个协议(如 add → stir → heatchill)"""
|
||||
|
||||
def test_sequential_protocol_execution(self):
|
||||
"""模拟典型合成路径:add → stir → heatchill"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_protocol
|
||||
from unilabos.compile.add_protocol import generate_add_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", uuid="uuid-reactor-01",
|
||||
klass="virtual_stirrer", type_="device",
|
||||
)]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
G = _build_test_graph()
|
||||
|
||||
# 每次调用用 enriched 的副本,避免编译器修改原数据
|
||||
all_actions = []
|
||||
|
||||
# 步骤1: 添加试剂
|
||||
add_actions = generate_add_protocol(
|
||||
G=G, vessel=copy.deepcopy(enriched),
|
||||
reagent="NaCl", mass="5 g",
|
||||
)
|
||||
all_actions.extend(add_actions)
|
||||
|
||||
# 步骤2: 搅拌
|
||||
stir_actions = generate_stir_protocol(
|
||||
G=G, vessel=copy.deepcopy(enriched),
|
||||
time="60", stir_speed=300.0,
|
||||
)
|
||||
all_actions.extend(stir_actions)
|
||||
|
||||
# 步骤3: 加热
|
||||
heat_actions = generate_heat_chill_protocol(
|
||||
G=G, vessel=copy.deepcopy(enriched),
|
||||
temp=80.0, time="300",
|
||||
)
|
||||
all_actions.extend(heat_actions)
|
||||
|
||||
# 验证总动作列表
|
||||
assert len(all_actions) >= 3
|
||||
# 每个协议至少产生一个核心动作
|
||||
action_names = [a.get("action_name", "") for a in all_actions if isinstance(a, dict)]
|
||||
assert "stir" in action_names
|
||||
assert "heat_chill" in action_names
|
||||
|
||||
def test_enriched_resource_not_mutated(self):
|
||||
"""验证编译器不应修改传入的 enriched dict(如果需要修改应 deepcopy)"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
original_id = enriched["id"]
|
||||
original_uuid = enriched["uuid"]
|
||||
|
||||
G = _build_test_graph()
|
||||
generate_stir_protocol(G=G, vessel=enriched, time="60")
|
||||
|
||||
# 验证 enriched dict 核心字段未被修改
|
||||
assert enriched["id"] == original_id
|
||||
assert enriched["uuid"] == original_uuid
|
||||
538
tests/compile/test_pump_separate_full_chain.py
Normal file
538
tests/compile/test_pump_separate_full_chain.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
PumpTransfer 和 Separate 全链路测试
|
||||
|
||||
构建包含泵/阀门/分液漏斗的完整设备图,
|
||||
输出完整的中间数据(最短路径、泵骨架、动作列表等)。
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import pprint
|
||||
import pytest
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.compile.utils.resource_helper import get_resource_id, get_resource_data
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
|
||||
def _make_raw_resource(id, uuid=None, name=None, klass="", type_="device",
|
||||
parent=None, parent_uuid=None, data=None, config=None, extra=None):
|
||||
return {
|
||||
"id": id,
|
||||
"uuid": uuid or f"uuid-{id}",
|
||||
"name": name or id,
|
||||
"class": klass,
|
||||
"type": type_,
|
||||
"parent": parent,
|
||||
"parent_uuid": parent_uuid or "",
|
||||
"description": "",
|
||||
"config": config or {},
|
||||
"data": data or {},
|
||||
"extra": extra or {},
|
||||
"position": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
}
|
||||
|
||||
|
||||
def _simulate_enrichment(raw_data_list):
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data_list)
|
||||
root = tree_set.trees[0].root_node if tree_set.trees else None
|
||||
return root.get_plr_nested_dict() if root else {}
|
||||
|
||||
|
||||
def _build_pump_transfer_graph():
|
||||
"""
|
||||
构建带泵/阀门的设备图,用于测试 PumpTransfer:
|
||||
|
||||
flask_water (container)
|
||||
↓
|
||||
valve_1 (multiway_valve, pump_1 连接)
|
||||
↓
|
||||
reactor_01 (device)
|
||||
|
||||
同时有: stirrer_1, heatchill_1, separator_1
|
||||
"""
|
||||
G = nx.DiGraph()
|
||||
|
||||
# 源容器
|
||||
G.add_node("flask_water", **{
|
||||
"id": "flask_water", "name": "flask_water",
|
||||
"type": "container", "class": "",
|
||||
"data": {"reagent_name": "water", "liquid": [{"liquid_type": "water", "volume": 200.0}]},
|
||||
"config": {"reagent": "water"},
|
||||
})
|
||||
|
||||
# 多通阀
|
||||
G.add_node("valve_1", **{
|
||||
"id": "valve_1", "name": "valve_1",
|
||||
"type": "device", "class": "multiway_valve",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 注射泵(连接到阀门)
|
||||
G.add_node("pump_1", **{
|
||||
"id": "pump_1", "name": "pump_1",
|
||||
"type": "device", "class": "virtual_pump",
|
||||
"data": {}, "config": {"max_volume": 25.0},
|
||||
})
|
||||
|
||||
# 目标容器
|
||||
G.add_node("reactor_01", **{
|
||||
"id": "reactor_01", "name": "reactor_01",
|
||||
"type": "device", "class": "virtual_stirrer",
|
||||
"data": {"liquid": [{"liquid_type": "water", "volume": 50.0}]},
|
||||
"config": {},
|
||||
})
|
||||
|
||||
# 搅拌器
|
||||
G.add_node("stirrer_1", **{
|
||||
"id": "stirrer_1", "name": "stirrer_1",
|
||||
"type": "device", "class": "virtual_stirrer",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 加热器
|
||||
G.add_node("heatchill_1", **{
|
||||
"id": "heatchill_1", "name": "heatchill_1",
|
||||
"type": "device", "class": "virtual_heatchill",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 分离器
|
||||
G.add_node("separator_1", **{
|
||||
"id": "separator_1", "name": "separator_1",
|
||||
"type": "device", "class": "separator_controller",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 废液容器
|
||||
G.add_node("waste_workup", **{
|
||||
"id": "waste_workup", "name": "waste_workup",
|
||||
"type": "container", "class": "",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 产物收集瓶
|
||||
G.add_node("product_flask", **{
|
||||
"id": "product_flask", "name": "product_flask",
|
||||
"type": "container", "class": "",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# DCM溶剂瓶
|
||||
G.add_node("flask_dcm", **{
|
||||
"id": "flask_dcm", "name": "flask_dcm",
|
||||
"type": "container", "class": "",
|
||||
"data": {"reagent_name": "dcm", "liquid": [{"liquid_type": "dcm", "volume": 500.0}]},
|
||||
"config": {"reagent": "dcm"},
|
||||
})
|
||||
|
||||
# 边连接 —— flask_water → valve_1 → reactor_01
|
||||
G.add_edge("flask_water", "valve_1", port={"valve_1": "port_1"})
|
||||
G.add_edge("valve_1", "reactor_01", port={"valve_1": "port_2"})
|
||||
# 阀门 → 泵
|
||||
G.add_edge("valve_1", "pump_1")
|
||||
G.add_edge("pump_1", "valve_1")
|
||||
# 搅拌器 ↔ reactor
|
||||
G.add_edge("stirrer_1", "reactor_01")
|
||||
# 加热器 ↔ reactor
|
||||
G.add_edge("heatchill_1", "reactor_01")
|
||||
# 分离器 ↔ reactor
|
||||
G.add_edge("separator_1", "reactor_01")
|
||||
G.add_edge("reactor_01", "separator_1")
|
||||
# DCM → valve → reactor (同一泵路)
|
||||
G.add_edge("flask_dcm", "valve_1", port={"valve_1": "port_3"})
|
||||
# reactor → valve → product/waste
|
||||
G.add_edge("valve_1", "product_flask", port={"valve_1": "port_4"})
|
||||
G.add_edge("valve_1", "waste_workup", port={"valve_1": "port_5"})
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def _format_action(action, indent=0):
|
||||
"""格式化单个 action 为可读字符串"""
|
||||
prefix = " " * indent
|
||||
if isinstance(action, list):
|
||||
# 并行动作
|
||||
lines = [f"{prefix}[PARALLEL]"]
|
||||
for sub in action:
|
||||
lines.append(_format_action(sub, indent + 1))
|
||||
return "\n".join(lines)
|
||||
|
||||
name = action.get("action_name", "?")
|
||||
device = action.get("device_id", "")
|
||||
kwargs = action.get("action_kwargs", {})
|
||||
comment = action.get("_comment", "")
|
||||
meta = action.get("_transfer_meta", "")
|
||||
|
||||
parts = [f"{prefix}→ {device}::{name}"]
|
||||
if kwargs:
|
||||
# 精简输出
|
||||
kw_str = ", ".join(f"{k}={v}" for k, v in kwargs.items()
|
||||
if k not in ("progress_message",))
|
||||
if kw_str:
|
||||
parts.append(f" kwargs: {{{kw_str}}}")
|
||||
if comment:
|
||||
parts.append(f" # {comment}")
|
||||
if meta:
|
||||
parts.append(f" meta: {meta}")
|
||||
return "\n".join(f"{prefix}{p}" if i > 0 else p for i, p in enumerate(parts))
|
||||
|
||||
|
||||
def _dump_actions(actions, title=""):
|
||||
"""打印完整动作列表"""
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {title}")
|
||||
print(f" 总动作数: {len(actions)}")
|
||||
print(f"{'='*70}")
|
||||
for i, action in enumerate(actions):
|
||||
print(f"\n [{i:02d}] {_format_action(action, indent=2)}")
|
||||
print(f"\n{'='*70}\n")
|
||||
|
||||
|
||||
# ==================== PumpTransfer 全链路 ====================
|
||||
|
||||
class TestPumpTransferFullChain:
|
||||
"""PumpTransfer: 包含图路径查找、泵骨架构建、动作序列生成"""
|
||||
|
||||
def test_pump_transfer_basic(self):
|
||||
"""基础泵转移:flask_water → valve_1 → reactor_01"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# 检查最短路径
|
||||
path = nx.shortest_path(G, "flask_water", "reactor_01")
|
||||
print(f"\n最短路径: {path}")
|
||||
assert "valve_1" in path
|
||||
|
||||
# 调用编译器
|
||||
actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel_id="flask_water",
|
||||
to_vessel_id="reactor_01",
|
||||
volume=10.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "PumpTransfer: flask_water → reactor_01, 10mL")
|
||||
|
||||
# 验证
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) > 0
|
||||
# 应该有 set_valve_position 和 set_position 动作
|
||||
flat = [a for a in actions if isinstance(a, dict)]
|
||||
action_names = [a.get("action_name") for a in flat]
|
||||
print(f"动作名称列表: {action_names}")
|
||||
assert "set_valve_position" in action_names
|
||||
assert "set_position" in action_names
|
||||
|
||||
def test_pump_transfer_with_rinsing_enriched_vessel(self):
|
||||
"""pump_with_rinsing 接收 enriched vessel dict"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# 模拟 enrichment
|
||||
from_raw = [_make_raw_resource(
|
||||
id="flask_water", klass="", type_="container",
|
||||
data={"reagent_name": "water", "liquid": [{"liquid_type": "water", "volume": 200.0}]},
|
||||
)]
|
||||
to_raw = [_make_raw_resource(
|
||||
id="reactor_01", klass="virtual_stirrer", type_="device",
|
||||
)]
|
||||
|
||||
from_enriched = _simulate_enrichment(from_raw)
|
||||
to_enriched = _simulate_enrichment(to_raw)
|
||||
|
||||
print(f"\nfrom_vessel enriched: {json.dumps(from_enriched, indent=2, ensure_ascii=False)[:300]}...")
|
||||
print(f"to_vessel enriched: {json.dumps(to_enriched, indent=2, ensure_ascii=False)[:300]}...")
|
||||
|
||||
# get_vessel 兼容
|
||||
fid, fdata = get_vessel(from_enriched)
|
||||
tid, tdata = get_vessel(to_enriched)
|
||||
print(f"from_vessel_id={fid}, to_vessel_id={tid}")
|
||||
assert fid == "flask_water"
|
||||
assert tid == "reactor_01"
|
||||
|
||||
actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=from_enriched,
|
||||
to_vessel=to_enriched,
|
||||
volume=15.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "PumpTransferWithRinsing: flask_water → reactor_01, 15mL (enriched)")
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) > 0
|
||||
|
||||
def test_pump_transfer_multi_batch(self):
|
||||
"""体积 > max_volume 时自动分批"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# pump_1 的 max_volume = 25mL,转 60mL 应该分 3 批
|
||||
actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel_id="flask_water",
|
||||
to_vessel_id="reactor_01",
|
||||
volume=60.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "PumpTransfer 分批: 60mL (max_volume=25mL, 预期 3 批)")
|
||||
|
||||
assert len(actions) > 0
|
||||
# 应该有多轮 set_position
|
||||
flat = [a for a in actions if isinstance(a, dict)]
|
||||
set_position_count = sum(1 for a in flat if a.get("action_name") == "set_position")
|
||||
print(f"set_position 动作数: {set_position_count}")
|
||||
# 3批 × 2次 (吸液 + 排液) = 6 次 set_position
|
||||
assert set_position_count >= 6
|
||||
|
||||
def test_pump_transfer_no_path(self):
|
||||
"""无路径时返回空"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
G.add_node("isolated_flask", type="container")
|
||||
|
||||
actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel_id="isolated_flask",
|
||||
to_vessel_id="reactor_01",
|
||||
volume=10.0,
|
||||
)
|
||||
|
||||
print(f"\n无路径时的动作列表: {actions}")
|
||||
assert actions == []
|
||||
|
||||
def test_pump_backbone_filtering(self):
|
||||
"""验证泵骨架过滤逻辑(电磁阀被跳过)"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
# 添加电磁阀到路径中
|
||||
G.add_node("solenoid_valve_1", **{
|
||||
"type": "device", "class": "solenoid_valve",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
# flask_water → solenoid_valve_1 → valve_1 → reactor_01
|
||||
G.remove_edge("flask_water", "valve_1")
|
||||
G.add_edge("flask_water", "solenoid_valve_1")
|
||||
G.add_edge("solenoid_valve_1", "valve_1")
|
||||
|
||||
path = nx.shortest_path(G, "flask_water", "reactor_01")
|
||||
print(f"\n含电磁阀的路径: {path}")
|
||||
assert "solenoid_valve_1" in path
|
||||
|
||||
actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel_id="flask_water",
|
||||
to_vessel_id="reactor_01",
|
||||
volume=10.0,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "PumpTransfer 含电磁阀: flask_water → solenoid → valve_1 → reactor_01")
|
||||
# 电磁阀应被跳过,泵骨架只有 valve_1
|
||||
assert len(actions) > 0
|
||||
|
||||
|
||||
# ==================== Separate 全链路 ====================
|
||||
|
||||
class TestSeparateProtocolFullChain:
|
||||
"""Separate: 包含 bug 确认和正常路径测试"""
|
||||
|
||||
def test_separate_bug_line_128_fixed(self):
|
||||
"""验证 separate_protocol.py:128 的 bug 已修复(不再 crash)"""
|
||||
from unilabos.compile.separate_protocol import generate_separate_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", klass="virtual_stirrer",
|
||||
data={"liquid": [{"liquid_type": "water", "volume": 100.0}]},
|
||||
)]
|
||||
enriched = _simulate_enrichment(raw_data)
|
||||
|
||||
# 修复前:final_vessel_id, _ = vessel_id 会 crash(字符串解包)
|
||||
# 修复后:final_vessel_id = vessel_id,正常返回 action 列表
|
||||
result = generate_separate_protocol(
|
||||
G=G,
|
||||
vessel=enriched,
|
||||
purpose="extract",
|
||||
product_phase="top",
|
||||
product_vessel="product_flask",
|
||||
waste_vessel="waste_workup",
|
||||
solvent="dcm",
|
||||
volume="100 mL",
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_separate_manual_workaround(self):
|
||||
"""
|
||||
绕过 line 128 bug,手动测试分离编译器中可以工作的子函数
|
||||
"""
|
||||
from unilabos.compile.separate_protocol import (
|
||||
find_separator_device,
|
||||
find_separation_vessel_bottom,
|
||||
)
|
||||
from unilabos.compile.utils.vessel_parser import (
|
||||
find_connected_stirrer,
|
||||
find_solvent_vessel,
|
||||
)
|
||||
from unilabos.compile.utils.unit_parser import parse_volume_input
|
||||
from unilabos.compile.utils.resource_helper import get_resource_liquid_volume as get_vessel_liquid_volume
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# 1. get_vessel 解析 enriched dict
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", klass="virtual_stirrer",
|
||||
data={"liquid": [{"liquid_type": "water", "volume": 100.0}]},
|
||||
)]
|
||||
enriched = _simulate_enrichment(raw_data)
|
||||
vessel_id, vessel_data = get_vessel(enriched)
|
||||
print(f"\nvessel_id: {vessel_id}")
|
||||
print(f"vessel_data: {vessel_data}")
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["liquid"][0]["volume"] == 100.0
|
||||
|
||||
# 2. find_separator_device
|
||||
sep = find_separator_device(G, vessel_id)
|
||||
print(f"分离器设备: {sep}")
|
||||
assert sep == "separator_1"
|
||||
|
||||
# 3. find_connected_stirrer
|
||||
stirrer = find_connected_stirrer(G, vessel_id)
|
||||
print(f"搅拌器设备: {stirrer}")
|
||||
assert stirrer == "stirrer_1"
|
||||
|
||||
# 4. find_solvent_vessel
|
||||
solvent_v = find_solvent_vessel(G, "dcm")
|
||||
print(f"DCM溶剂容器: {solvent_v}")
|
||||
assert solvent_v == "flask_dcm"
|
||||
|
||||
# 5. parse_volume_input
|
||||
vol = parse_volume_input("200 mL")
|
||||
print(f"体积解析: '200 mL' → {vol}")
|
||||
assert vol == 200.0
|
||||
|
||||
vol2 = parse_volume_input("1.5 L")
|
||||
print(f"体积解析: '1.5 L' → {vol2}")
|
||||
assert vol2 == 1500.0
|
||||
|
||||
# 6. get_vessel_liquid_volume
|
||||
liq_vol = get_vessel_liquid_volume(enriched)
|
||||
print(f"液体体积 (enriched dict): {liq_vol}")
|
||||
assert liq_vol == 100.0
|
||||
|
||||
# 7. find_separation_vessel_bottom
|
||||
bottom = find_separation_vessel_bottom(G, vessel_id)
|
||||
print(f"分离容器底部: {bottom}")
|
||||
# 当前图中没有命名匹配的底部容器
|
||||
|
||||
def test_pump_transfer_for_separate_subflow(self):
|
||||
"""测试 separate 中调用的 pump 子流程(溶剂添加 → 分液漏斗)"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# 模拟分离前的溶剂添加步骤
|
||||
actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel="flask_dcm",
|
||||
to_vessel="reactor_01",
|
||||
volume=100.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "Separate 子流程: flask_dcm → reactor_01, 100mL DCM")
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) > 0
|
||||
|
||||
# 模拟分离后产物转移
|
||||
actions2 = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel="reactor_01",
|
||||
to_vessel="product_flask",
|
||||
volume=50.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions2, "Separate 子流程: reactor_01 → product_flask, 50mL 产物")
|
||||
|
||||
assert len(actions2) > 0
|
||||
|
||||
# 废液转移
|
||||
actions3 = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel="reactor_01",
|
||||
to_vessel="waste_workup",
|
||||
volume=50.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions3, "Separate 子流程: reactor_01 → waste_workup, 50mL 废液")
|
||||
|
||||
assert len(actions3) > 0
|
||||
|
||||
|
||||
# ==================== 图路径可视化 ====================
|
||||
|
||||
class TestGraphPathVisualization:
|
||||
"""输出图中关键路径信息"""
|
||||
|
||||
def test_all_shortest_paths(self):
|
||||
"""输出所有容器之间的最短路径"""
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
containers = [n for n in G.nodes() if G.nodes[n].get("type") == "container"]
|
||||
devices = [n for n in G.nodes() if G.nodes[n].get("type") == "device"]
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" 设备图概览")
|
||||
print(f"{'='*70}")
|
||||
print(f" 容器节点 ({len(containers)}): {containers}")
|
||||
print(f" 设备节点 ({len(devices)}): {devices}")
|
||||
print(f" 边数: {G.number_of_edges()}")
|
||||
print(f" 边列表:")
|
||||
for u, v, data in G.edges(data=True):
|
||||
port_info = data.get("port", "")
|
||||
print(f" {u} → {v} {port_info if port_info else ''}")
|
||||
|
||||
print(f"\n 关键路径:")
|
||||
pairs = [
|
||||
("flask_water", "reactor_01"),
|
||||
("flask_dcm", "reactor_01"),
|
||||
("reactor_01", "product_flask"),
|
||||
("reactor_01", "waste_workup"),
|
||||
("flask_water", "product_flask"),
|
||||
]
|
||||
for src, dst in pairs:
|
||||
try:
|
||||
path = nx.shortest_path(G, src, dst)
|
||||
length = len(path) - 1
|
||||
# 标注路径上的节点类型
|
||||
annotated = []
|
||||
for n in path:
|
||||
ntype = G.nodes[n].get("type", "?")
|
||||
nclass = G.nodes[n].get("class", "")
|
||||
annotated.append(f"{n}({ntype}{'/' + nclass if nclass else ''})")
|
||||
print(f" {src} → {dst}: 距离={length}")
|
||||
print(f" 路径: {' → '.join(annotated)}")
|
||||
except nx.NetworkXNoPath:
|
||||
print(f" {src} → {dst}: 无路径!")
|
||||
|
||||
print(f"{'='*70}\n")
|
||||
324
tests/compile/test_resource_conversion_path.py
Normal file
324
tests/compile/test_resource_conversion_path.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
ROS Goal → Resource 转换 → 编译器路径的集成测试
|
||||
|
||||
覆盖:
|
||||
1. Resource.msg 新字段(uuid, klass, extra)的往返转换
|
||||
2. dict → ROS Resource → dict 往返无损
|
||||
3. ResourceTreeSet → get_plr_nested_dict 保留 children 结构
|
||||
4. resource_helper 兼容 dict / ResourceDictInstance
|
||||
5. vessel_parser.get_vessel 兼容 ResourceDictInstance
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
# 不依赖 ROS 的测试 —— 直接测试 resource 处理路径
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeInstance,
|
||||
ResourceTreeSet,
|
||||
)
|
||||
from unilabos.compile.utils.resource_helper import (
|
||||
ensure_resource_instance,
|
||||
resource_to_dict,
|
||||
get_resource_id,
|
||||
get_resource_data,
|
||||
get_resource_display_info,
|
||||
get_resource_liquid_volume,
|
||||
)
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
|
||||
# ============ 构建测试数据 ============
|
||||
|
||||
|
||||
def _make_resource_dict(
|
||||
id="reactor_01",
|
||||
uuid="uuid-reactor-01",
|
||||
name="reactor_01",
|
||||
klass="virtual_stirrer",
|
||||
type_="device",
|
||||
parent=None,
|
||||
parent_uuid=None,
|
||||
data=None,
|
||||
config=None,
|
||||
extra=None,
|
||||
):
|
||||
return {
|
||||
"id": id,
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"class": klass,
|
||||
"type": type_,
|
||||
"parent": parent,
|
||||
"parent_uuid": parent_uuid or "",
|
||||
"description": "",
|
||||
"config": config or {},
|
||||
"data": data or {},
|
||||
"extra": extra or {},
|
||||
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
|
||||
}
|
||||
|
||||
|
||||
def _make_resource_instance(id="reactor_01", **kwargs):
|
||||
d = _make_resource_dict(id=id, **kwargs)
|
||||
return ResourceDictInstance.get_resource_instance_from_dict(d)
|
||||
|
||||
|
||||
def _make_tree_with_children():
|
||||
"""构建 StationA -> [R1, R2] 的资源树"""
|
||||
raw_data = [
|
||||
_make_resource_dict(
|
||||
id="StationA",
|
||||
uuid="uuid-station-a",
|
||||
name="StationA",
|
||||
klass="workstation",
|
||||
type_="device",
|
||||
),
|
||||
_make_resource_dict(
|
||||
id="R1",
|
||||
uuid="uuid-r1",
|
||||
name="R1",
|
||||
klass="",
|
||||
type_="resource",
|
||||
parent="StationA",
|
||||
parent_uuid="uuid-station-a",
|
||||
data={"liquid": [{"liquid_type": "water", "volume": 10.0}]},
|
||||
),
|
||||
_make_resource_dict(
|
||||
id="R2",
|
||||
uuid="uuid-r2",
|
||||
name="R2",
|
||||
klass="",
|
||||
type_="resource",
|
||||
parent="StationA",
|
||||
parent_uuid="uuid-station-a",
|
||||
data={"liquid": [{"liquid_type": "ethanol", "volume": 5.0}]},
|
||||
),
|
||||
]
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
return tree_set
|
||||
|
||||
|
||||
# ============ resource_helper 测试 ============
|
||||
|
||||
|
||||
class TestResourceHelper:
|
||||
"""测试 resource_helper 对 dict / ResourceDictInstance 的兼容性"""
|
||||
|
||||
def test_ensure_resource_instance_from_dict(self):
|
||||
d = _make_resource_dict()
|
||||
inst = ensure_resource_instance(d)
|
||||
assert isinstance(inst, ResourceDictInstance)
|
||||
assert inst.res_content.id == "reactor_01"
|
||||
assert inst.res_content.uuid == "uuid-reactor-01"
|
||||
|
||||
def test_ensure_resource_instance_passthrough(self):
|
||||
inst = _make_resource_instance()
|
||||
result = ensure_resource_instance(inst)
|
||||
assert result is inst # 同一个对象,不复制
|
||||
|
||||
def test_ensure_resource_instance_none(self):
|
||||
assert ensure_resource_instance(None) is None
|
||||
|
||||
def test_get_resource_id_from_dict(self):
|
||||
d = _make_resource_dict(id="my_device")
|
||||
assert get_resource_id(d) == "my_device"
|
||||
|
||||
def test_get_resource_id_from_instance(self):
|
||||
inst = _make_resource_instance(id="my_device")
|
||||
assert get_resource_id(inst) == "my_device"
|
||||
|
||||
def test_get_resource_id_from_string(self):
|
||||
assert get_resource_id("my_device") == "my_device"
|
||||
|
||||
def test_get_resource_id_from_wrapped_dict(self):
|
||||
"""兼容 {station_id: {...}} 格式"""
|
||||
d = {"StationA": {"id": "StationA", "name": "StationA"}}
|
||||
assert get_resource_id(d) == "StationA"
|
||||
|
||||
def test_get_resource_data_from_dict(self):
|
||||
d = _make_resource_dict(data={"temperature": 25.0})
|
||||
assert get_resource_data(d) == {"temperature": 25.0}
|
||||
|
||||
def test_get_resource_data_from_instance(self):
|
||||
inst = _make_resource_instance(data={"temperature": 25.0})
|
||||
data = get_resource_data(inst)
|
||||
assert data["temperature"] == 25.0
|
||||
|
||||
def test_get_resource_display_info_from_dict(self):
|
||||
d = _make_resource_dict(id="reactor_01", name="Reactor #1")
|
||||
info = get_resource_display_info(d)
|
||||
assert "reactor_01" in info
|
||||
assert "Reactor #1" in info
|
||||
|
||||
def test_get_resource_display_info_from_instance(self):
|
||||
inst = _make_resource_instance(id="reactor_01", name="Reactor #1")
|
||||
info = get_resource_display_info(inst)
|
||||
assert "reactor_01" in info
|
||||
|
||||
def test_get_resource_display_info_from_string(self):
|
||||
assert get_resource_display_info("reactor_01") == "reactor_01"
|
||||
|
||||
def test_get_resource_liquid_volume(self):
|
||||
d = _make_resource_dict(data={"liquid": [{"liquid_type": "water", "volume": 15.5}]})
|
||||
assert get_resource_liquid_volume(d) == pytest.approx(15.5)
|
||||
|
||||
def test_resource_to_dict_from_instance(self):
|
||||
inst = _make_resource_instance(id="reactor_01", klass="virtual_stirrer")
|
||||
d = resource_to_dict(inst)
|
||||
assert isinstance(d, dict)
|
||||
assert d["id"] == "reactor_01"
|
||||
assert d["class"] == "virtual_stirrer"
|
||||
|
||||
def test_resource_to_dict_passthrough(self):
|
||||
d = _make_resource_dict()
|
||||
result = resource_to_dict(d)
|
||||
assert result is d # 同一个 dict
|
||||
|
||||
|
||||
# ============ vessel_parser 兼容性测试 ============
|
||||
|
||||
|
||||
class TestVesselParser:
|
||||
"""测试 vessel_parser.get_vessel 对 ResourceDictInstance 的兼容"""
|
||||
|
||||
def test_get_vessel_from_dict(self):
|
||||
d = _make_resource_dict(id="reactor_01", data={"temperature": 25.0})
|
||||
vessel_id, vessel_data = get_vessel(d)
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["temperature"] == 25.0
|
||||
|
||||
def test_get_vessel_from_string(self):
|
||||
vessel_id, vessel_data = get_vessel("reactor_01")
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data == {}
|
||||
|
||||
def test_get_vessel_from_resource_instance(self):
|
||||
inst = _make_resource_instance(id="reactor_01", data={"temperature": 25.0})
|
||||
vessel_id, vessel_data = get_vessel(inst)
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["temperature"] == 25.0
|
||||
|
||||
def test_get_vessel_from_wrapped_dict(self):
|
||||
"""兼容 {station_id: {id: ..., data: {...}}} 格式"""
|
||||
d = {"StationA": {"id": "StationA", "data": {"vol": 100}}}
|
||||
vessel_id, vessel_data = get_vessel(d)
|
||||
assert vessel_id == "StationA"
|
||||
|
||||
|
||||
# ============ ResourceTreeSet → get_plr_nested_dict 测试 ============
|
||||
|
||||
|
||||
class TestResourceTreeRoundTrip:
|
||||
"""测试 ResourceTreeSet → get_plr_nested_dict 保留树结构和关键字段"""
|
||||
|
||||
def test_tree_preserves_children(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
assert len(tree_set.trees) == 1
|
||||
root = tree_set.trees[0].root_node
|
||||
assert root.res_content.id == "StationA"
|
||||
assert len(root.children) == 2
|
||||
|
||||
def test_plr_nested_dict_has_children(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
assert isinstance(nested, dict)
|
||||
assert "children" in nested
|
||||
assert isinstance(nested["children"], dict)
|
||||
assert "R1" in nested["children"]
|
||||
assert "R2" in nested["children"]
|
||||
|
||||
def test_plr_nested_dict_preserves_uuid(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
assert nested["uuid"] == "uuid-station-a"
|
||||
assert nested["children"]["R1"]["uuid"] == "uuid-r1"
|
||||
|
||||
def test_plr_nested_dict_preserves_klass(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
assert nested["class"] == "workstation"
|
||||
|
||||
def test_plr_nested_dict_preserves_data(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
r1_data = nested["children"]["R1"]["data"]
|
||||
assert "liquid" in r1_data
|
||||
assert r1_data["liquid"][0]["volume"] == 10.0
|
||||
|
||||
def test_plr_nested_dict_usable_by_get_vessel(self):
|
||||
"""get_plr_nested_dict 的结果可以直接传给 get_vessel"""
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
vessel_id, vessel_data = get_vessel(nested)
|
||||
assert vessel_id == "StationA"
|
||||
|
||||
def test_dump_vs_plr_nested_dict(self):
|
||||
"""dump() 是扁平化的,get_plr_nested_dict 保留树结构"""
|
||||
tree_set = _make_tree_with_children()
|
||||
# dump 返回扁平列表
|
||||
dumped = tree_set.dump()
|
||||
assert isinstance(dumped[0], list)
|
||||
assert len(dumped[0]) == 3 # StationA + R1 + R2,全部扁平
|
||||
|
||||
# get_plr_nested_dict 保留嵌套
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
assert isinstance(nested["children"], dict)
|
||||
assert len(nested["children"]) == 2 # 嵌套的 children
|
||||
|
||||
|
||||
# ============ 模拟 workstation 路径测试 ============
|
||||
|
||||
|
||||
class TestWorkstationPath:
|
||||
"""模拟 workstation.py 中的关键路径:
|
||||
raw_data → ResourceTreeSet.from_raw_dict_list → get_plr_nested_dict → compiler
|
||||
"""
|
||||
|
||||
def test_single_resource_path(self):
|
||||
"""单个 Resource: 取第一棵树的根节点"""
|
||||
raw_data = [
|
||||
_make_resource_dict(id="reactor_01", uuid="uuid-r01", klass="virtual_stirrer"),
|
||||
]
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
root = tree_set.trees[0].root_node
|
||||
result = root.get_plr_nested_dict()
|
||||
assert result["id"] == "reactor_01"
|
||||
assert result["uuid"] == "uuid-r01"
|
||||
assert result["class"] == "virtual_stirrer"
|
||||
|
||||
def test_resource_with_children_path(self):
|
||||
"""Resource 带 children: AGV/batch transfer 场景"""
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
|
||||
# 模拟编译器接收到的参数
|
||||
from_repo = {"StationA": nested}
|
||||
assert "A01" not in from_repo["StationA"]["children"] # children 按 id 索引
|
||||
assert "R1" in from_repo["StationA"]["children"]
|
||||
assert from_repo["StationA"]["children"]["R1"]["uuid"] == "uuid-r1"
|
||||
|
||||
def test_multiple_resource_path(self):
|
||||
"""多个 Resource: 每棵树取根节点"""
|
||||
raw_data1 = [_make_resource_dict(id="R1", uuid="uuid-r1")]
|
||||
raw_data2 = [_make_resource_dict(id="R2", uuid="uuid-r2")]
|
||||
# 模拟 host 返回多棵树
|
||||
tree_set1 = ResourceTreeSet.from_raw_dict_list(raw_data1)
|
||||
tree_set2 = ResourceTreeSet.from_raw_dict_list(raw_data2)
|
||||
results = [
|
||||
tree.root_node.get_plr_nested_dict()
|
||||
for ts in [tree_set1, tree_set2]
|
||||
for tree in ts.trees
|
||||
]
|
||||
assert len(results) == 2
|
||||
assert results[0]["id"] == "R1"
|
||||
assert results[1]["id"] == "R2"
|
||||
137
tests/devices/test_agv_transport_station.py
Normal file
137
tests/devices/test_agv_transport_station.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
AGVTransportStation driver 测试
|
||||
|
||||
覆盖:初始化、carrier property、slot 查询、路由查询、capacity 计算。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from unilabos.devices.transport.agv_workstation import AGVTransportStation
|
||||
from unilabos.resources.warehouse import WareHouse, warehouse_factory
|
||||
|
||||
|
||||
class TestAGVTransportStation:
|
||||
def _make_driver(self, route_table=None, device_roles=None):
|
||||
"""创建一个 AGVTransportStation 实例"""
|
||||
return AGVTransportStation(
|
||||
deck=None,
|
||||
route_table=route_table or {
|
||||
"A->B": {"nav_command": '{"target":"LM1"}', "arm_pick": "pick.urp", "arm_place": "place.urp"}
|
||||
},
|
||||
device_roles=device_roles or {"navigator": "agv_nav", "arm": "agv_arm"},
|
||||
)
|
||||
|
||||
def _make_warehouse(self, name="agv_platform", nx=2, ny=1, nz=1):
|
||||
"""创建一个测试用 Warehouse"""
|
||||
return warehouse_factory(name=name, num_items_x=nx, num_items_y=ny, num_items_z=nz)
|
||||
|
||||
def test_init_deck_none(self):
|
||||
"""AGVTransportStation 初始化时 deck=None"""
|
||||
driver = self._make_driver()
|
||||
assert driver.deck is None
|
||||
|
||||
def test_init_route_table(self):
|
||||
"""路由表正确存储"""
|
||||
driver = self._make_driver()
|
||||
assert "A->B" in driver.route_table
|
||||
|
||||
def test_init_device_roles(self):
|
||||
"""设备角色正确存储"""
|
||||
driver = self._make_driver()
|
||||
assert driver.device_roles["navigator"] == "agv_nav"
|
||||
assert driver.device_roles["arm"] == "agv_arm"
|
||||
|
||||
def test_carrier_without_ros_node(self):
|
||||
"""未 post_init 时 carrier 返回 None"""
|
||||
driver = self._make_driver()
|
||||
assert driver.carrier is None
|
||||
|
||||
def test_carrier_with_warehouse(self):
|
||||
"""post_init 后 carrier 返回正确的 WareHouse"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse()
|
||||
|
||||
# 模拟 ros_node 和 resource_tracker
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
assert driver.carrier is wh
|
||||
assert isinstance(driver.carrier, WareHouse)
|
||||
|
||||
def test_capacity(self):
|
||||
"""容量计算正确"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse(nx=2, ny=1, nz=1)
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
assert driver.capacity == 2
|
||||
|
||||
def test_capacity_multi_layer(self):
|
||||
"""多层 Warehouse 容量"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse(nx=1, ny=2, nz=3)
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
assert driver.capacity == 6
|
||||
|
||||
def test_capacity_no_carrier(self):
|
||||
"""无 carrier 时容量为 0"""
|
||||
driver = self._make_driver()
|
||||
assert driver.capacity == 0
|
||||
|
||||
def test_free_slots(self):
|
||||
"""空载时所有 slot 为空闲"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse(nx=2, ny=1, nz=1)
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
free = driver.free_slots
|
||||
assert len(free) == 2
|
||||
|
||||
def test_occupied_slots_empty(self):
|
||||
"""空载时 occupied_slots 为空"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse(nx=2, ny=1, nz=1)
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
assert len(driver.occupied_slots) == 0
|
||||
|
||||
def test_resolve_route(self):
|
||||
"""路由查询返回正确的指令"""
|
||||
driver = self._make_driver()
|
||||
route = driver.resolve_route("A", "B")
|
||||
assert route["nav_command"] == '{"target":"LM1"}'
|
||||
assert route["arm_pick"] == "pick.urp"
|
||||
|
||||
def test_resolve_route_not_found(self):
|
||||
"""查询不存在的路线时抛出 KeyError"""
|
||||
driver = self._make_driver()
|
||||
with pytest.raises(KeyError, match="路由表"):
|
||||
driver.resolve_route("X", "Y")
|
||||
|
||||
def test_get_device_id(self):
|
||||
"""获取子设备 ID"""
|
||||
driver = self._make_driver()
|
||||
assert driver.get_device_id("navigator") == "agv_nav"
|
||||
assert driver.get_device_id("arm") == "agv_arm"
|
||||
|
||||
def test_get_device_id_not_found(self):
|
||||
"""获取不存在的角色时抛出 KeyError"""
|
||||
driver = self._make_driver()
|
||||
with pytest.raises(KeyError, match="未配置设备角色"):
|
||||
driver.get_device_id("gripper")
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.18"
|
||||
__version__ = "0.10.19"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -24,6 +26,84 @@ from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
_restart_requested: bool = False
|
||||
_restart_reason: str = ""
|
||||
|
||||
RESTART_EXIT_CODE = 42
|
||||
|
||||
|
||||
def _build_child_argv():
|
||||
"""Build sys.argv for child process, stripping supervisor-only arguments."""
|
||||
result = []
|
||||
skip_next = False
|
||||
for arg in sys.argv:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg in ("--restart_mode", "--restart-mode"):
|
||||
continue
|
||||
if arg in ("--auto_restart_count", "--auto-restart-count"):
|
||||
skip_next = True
|
||||
continue
|
||||
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
|
||||
continue
|
||||
result.append(arg)
|
||||
return result
|
||||
|
||||
|
||||
def _run_as_supervisor(max_restarts: int):
|
||||
"""
|
||||
Supervisor process that spawns and monitors child processes.
|
||||
|
||||
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
|
||||
it only launches the real process as a child and restarts it when the child
|
||||
exits with RESTART_EXIT_CODE.
|
||||
"""
|
||||
child_argv = [sys.executable] + _build_child_argv()
|
||||
restart_count = 0
|
||||
|
||||
print_status(
|
||||
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
|
||||
f"child command: {' '.join(child_argv)}",
|
||||
"info",
|
||||
)
|
||||
|
||||
while True:
|
||||
print_status(
|
||||
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
|
||||
"info",
|
||||
)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(child_argv)
|
||||
exit_code = process.wait()
|
||||
except KeyboardInterrupt:
|
||||
print_status("[Supervisor] Interrupted, terminating child process...", "info")
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
if exit_code == RESTART_EXIT_CODE:
|
||||
restart_count += 1
|
||||
if restart_count > max_restarts:
|
||||
print_status(
|
||||
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
|
||||
"warning",
|
||||
)
|
||||
sys.exit(1)
|
||||
print_status(
|
||||
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
|
||||
"info",
|
||||
)
|
||||
time.sleep(2)
|
||||
else:
|
||||
if exit_code != 0:
|
||||
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
|
||||
else:
|
||||
print_status("[Supervisor] Child exited normally", "info")
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
@@ -65,6 +145,13 @@ def parse_args():
|
||||
action="append",
|
||||
help="Path to the registry directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--devices",
|
||||
type=str,
|
||||
default=None,
|
||||
action="append",
|
||||
help="Path to Python code directory for AST-based device/resource scanning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--working_dir",
|
||||
type=str,
|
||||
@@ -154,18 +241,18 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Skip environment dependency check on startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complete_registry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complete_registry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Complete and rewrite YAML registry files using AST analysis results",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no_update_feedback",
|
||||
action="store_true",
|
||||
@@ -177,6 +264,30 @@ def parse_args():
|
||||
default=False,
|
||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--external_devices_only",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra_resource",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restart_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto_restart_count",
|
||||
type=int,
|
||||
default=500,
|
||||
help="Maximum number of automatic restarts in restart mode (default: 500)",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
@@ -227,16 +338,28 @@ def main():
|
||||
args = parser.parse_args()
|
||||
args_dict = vars(args)
|
||||
|
||||
# Supervisor mode: spawn child processes and monitor for restart
|
||||
if args_dict.get("restart_mode", False):
|
||||
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
|
||||
return
|
||||
|
||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||
skip_env_check = args_dict.get("skip_env_check", False)
|
||||
check_mode = args_dict.get("check_mode", False)
|
||||
|
||||
if not skip_env_check:
|
||||
from unilabos.utils.environment_check import check_environment
|
||||
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
|
||||
|
||||
if not check_environment(auto_install=True):
|
||||
print_status("环境检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
|
||||
# 第一次设备包依赖检查:build_registry 之前,确保 import map 可用
|
||||
devices_dirs_for_req = args_dict.get("devices", None)
|
||||
if devices_dirs_for_req:
|
||||
if not check_device_package_requirements(devices_dirs_for_req):
|
||||
print_status("设备包依赖检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
else:
|
||||
print_status("跳过环境依赖检查", "warning")
|
||||
|
||||
@@ -357,46 +480,63 @@ def main():
|
||||
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||
BasicConfig.extra_resource = args_dict.get("extra_resource", False)
|
||||
if BasicConfig.extra_resource:
|
||||
print_status("启用额外资源加载:将加载lab_开头的labware资源定义", "info")
|
||||
BasicConfig.communication_protocol = "websocket"
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = platform.node()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
BasicConfig.machine_name = machine_name
|
||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||
BasicConfig.check_mode = check_mode
|
||||
|
||||
from unilabos.resources.graphio import (
|
||||
read_node_link_json,
|
||||
read_graphml,
|
||||
dict_from_graph,
|
||||
)
|
||||
from unilabos.app.communication import get_communication_client
|
||||
from unilabos.registry.registry import build_registry
|
||||
from unilabos.app.backend import start_backend
|
||||
from unilabos.app.web import http_client
|
||||
from unilabos.app.web import start_server
|
||||
from unilabos.app.register import register_devices_and_resources
|
||||
from unilabos.resources.graphio import modify_to_backend_format
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表 - check_mode 时强制启用 complete_registry
|
||||
# Step 0: AST 分析优先 + YAML 注册表加载
|
||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
||||
devices_dirs = args_dict.get("devices", None)
|
||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||
lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry)
|
||||
external_only = args_dict.get("external_devices_only", False)
|
||||
lab_registry = build_registry(
|
||||
registry_paths=args_dict["registry_path"],
|
||||
devices_dirs=devices_dirs,
|
||||
upload_registry=BasicConfig.upload_registry,
|
||||
check_mode=check_mode,
|
||||
complete_registry=complete_registry,
|
||||
external_only=external_only,
|
||||
)
|
||||
|
||||
# Check mode: complete_registry 完成后直接退出,git diff 检测由 CI workflow 执行
|
||||
# Check mode: 注册表验证完成后直接退出
|
||||
if check_mode:
|
||||
print_status("Check mode: complete_registry 完成,退出", "info")
|
||||
device_count = len(lab_registry.device_type_registry)
|
||||
resource_count = len(lab_registry.resource_type_registry)
|
||||
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
# 以下导入依赖 ROS2 环境,check_mode 已退出不需要
|
||||
from unilabos.resources.graphio import (
|
||||
read_node_link_json,
|
||||
read_graphml,
|
||||
dict_from_graph,
|
||||
modify_to_backend_format,
|
||||
)
|
||||
from unilabos.app.communication import get_communication_client
|
||||
from unilabos.app.backend import start_backend
|
||||
from unilabos.app.web import http_client
|
||||
from unilabos.app.web import start_server
|
||||
from unilabos.app.register import register_devices_and_resources
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
# print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
# print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
@@ -413,8 +553,13 @@ def main():
|
||||
os._exit(0)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("测试模式:跳过 ak/sk 检查,使用占位凭据", "warning")
|
||||
BasicConfig.ak = BasicConfig.ak or "test_ak"
|
||||
BasicConfig.sk = BasicConfig.sk or "test_sk"
|
||||
else:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
graph: nx.Graph
|
||||
resource_tree_set: ResourceTreeSet
|
||||
resource_links: List[Dict[str, Any]]
|
||||
@@ -481,12 +626,16 @@ def main():
|
||||
continue
|
||||
|
||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||
if request_startup_json and "nodes" in request_startup_json:
|
||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||
print_status("开始同步远端物料到本地...", "info")
|
||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||
print_status("远端物料同步完成", "info")
|
||||
|
||||
# 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements
|
||||
# TODO: 当 community device package 功能上线后,在这里调用
|
||||
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
|
||||
|
||||
# 使用 ResourceTreeSet 代替 list
|
||||
args_dict["resources_config"] = resource_tree_set
|
||||
args_dict["devices_config"] = resource_tree_set
|
||||
@@ -578,6 +727,10 @@ def main():
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
if restart_requested:
|
||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||
cleanup_for_restart()
|
||||
os._exit(RESTART_EXIT_CODE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.utils.type_check import TypeEncoder
|
||||
from unilabos.utils.tools import normalize_json as _normalize_device
|
||||
|
||||
|
||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||
@@ -11,50 +10,63 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
||||
注册设备和资源到服务器(仅支持HTTP)
|
||||
"""
|
||||
|
||||
# 注册资源信息 - 使用HTTP方式
|
||||
from unilabos.app.web.client import http_client
|
||||
|
||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||
|
||||
# 注册设备信息
|
||||
devices_to_register = {}
|
||||
for device_info in lab_registry.obtain_registry_device_info():
|
||||
devices_to_register[device_info["id"]] = json.loads(
|
||||
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
||||
)
|
||||
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
devices_to_register[device_info["id"]] = _normalize_device(device_info)
|
||||
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
|
||||
resources_to_register = {}
|
||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||
resources_to_register[resource_info["id"]] = resource_info
|
||||
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
|
||||
if gather_only:
|
||||
return devices_to_register, resources_to_register
|
||||
# 注册设备
|
||||
|
||||
if devices_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||
response = http_client.resource_registry(
|
||||
{"resources": list(devices_to_register.values())},
|
||||
tag="device_registry",
|
||||
)
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s")
|
||||
res_data = response.json() if response.status_code == 200 else {}
|
||||
skipped = res_data.get("data", {}).get("skipped", False)
|
||||
if skipped:
|
||||
logger.info(
|
||||
f"[UniLab Register] 设备注册跳过(内容未变化)"
|
||||
f" {len(devices_to_register)} 个 {cost_time:.3f}s"
|
||||
)
|
||||
elif response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||
|
||||
# 注册资源
|
||||
if resources_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||
response = http_client.resource_registry(
|
||||
{"resources": list(resources_to_register.values())},
|
||||
tag="resource_registry",
|
||||
)
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s")
|
||||
res_data = response.json() if response.status_code == 200 else {}
|
||||
skipped = res_data.get("data", {}).get("skipped", False)
|
||||
if skipped:
|
||||
logger.info(
|
||||
f"[UniLab Register] 资源注册跳过(内容未变化)"
|
||||
f" {len(resources_to_register)} 个 {cost_time:.3f}s"
|
||||
)
|
||||
elif response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||
|
||||
logger.info("[UniLab Register] 设备和资源注册完成.")
|
||||
|
||||
@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
|
||||
"result": {},
|
||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||
"handles": [],
|
||||
"handles": {},
|
||||
}
|
||||
# 不生成已配置action的动作
|
||||
for k, v in enhanced_info["action_methods"].items()
|
||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
||||
# 启动广播任务
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(broadcast_device_status())
|
||||
asyncio.create_task(broadcast_status_page_data())
|
||||
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
||||
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
||||
|
||||
@@ -3,11 +3,13 @@ HTTP客户端模块
|
||||
|
||||
提供与远程服务器通信的客户端功能,只有host需要用
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
|
||||
|
||||
import requests
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils.log import info
|
||||
@@ -280,22 +282,54 @@ class HTTPClient:
|
||||
)
|
||||
return response
|
||||
|
||||
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||
def resource_registry(
|
||||
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
||||
) -> requests.Response:
|
||||
"""
|
||||
注册资源到服务器
|
||||
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
||||
|
||||
Args:
|
||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
||||
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
# 序列化一次,同时用于保存和发送
|
||||
json_bytes = _fast_dumps(registry_data)
|
||||
|
||||
# 保存请求数据到 unilabos_data
|
||||
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
|
||||
try:
|
||||
os.makedirs(BasicConfig.working_dir, exist_ok=True)
|
||||
with open(req_path, "wb") as f:
|
||||
f.write(_fast_dumps_pretty(registry_data))
|
||||
logger.trace(f"注册表请求数据已保存: {req_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表请求数据失败: {e}")
|
||||
|
||||
compressed_body = gzip.compress(json_bytes)
|
||||
headers = {
|
||||
"Authorization": f"Lab {self.auth}",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
}
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/resource",
|
||||
json=registry_data,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
data=compressed_body,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# 保存响应数据到 unilabos_data
|
||||
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
|
||||
try:
|
||||
with open(res_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}\n{response.text}")
|
||||
logger.trace(f"注册表响应数据已保存: {res_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表响应数据失败: {e}")
|
||||
|
||||
if response.status_code not in [200, 201]:
|
||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -86,7 +86,7 @@ def setup_server() -> FastAPI:
|
||||
# 设置页面路由
|
||||
try:
|
||||
setup_web_pages(pages)
|
||||
info("[Web] 已加载Web UI模块")
|
||||
# info("[Web] 已加载Web UI模块")
|
||||
except ImportError as e:
|
||||
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
||||
except Exception as e:
|
||||
@@ -138,7 +138,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||
server_thread.start()
|
||||
|
||||
info("[Web] Server started, monitoring for restart requests...")
|
||||
# info("[Web] Server started, monitoring for restart requests...")
|
||||
|
||||
# 监控重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
@@ -26,6 +26,7 @@ from enum import Enum
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.app.model import JobAddReq
|
||||
from unilabos.resources.resource_tracker import ResourceDictType
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
from unilabos.app.communication import BaseCommunicationClient
|
||||
@@ -408,6 +409,7 @@ class MessageProcessor:
|
||||
# 线程控制
|
||||
self.is_running = False
|
||||
self.thread = None
|
||||
self._loop = None # asyncio event loop引用,用于外部关闭websocket
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
||||
@@ -434,22 +436,31 @@ class MessageProcessor:
|
||||
def stop(self) -> None:
|
||||
"""停止消息处理线程"""
|
||||
self.is_running = False
|
||||
# 主动关闭websocket以快速中断消息接收循环
|
||||
ws = self.websocket
|
||||
loop = self._loop
|
||||
if ws and loop and loop.is_running():
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(ws.close(), loop)
|
||||
except Exception:
|
||||
pass
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2)
|
||||
logger.info("[MessageProcessor] Stopped")
|
||||
|
||||
def _run(self):
|
||||
"""运行消息处理主循环"""
|
||||
loop = asyncio.new_event_loop()
|
||||
self._loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(self._connection_handler())
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.run_until_complete(self._connection_handler())
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
if loop:
|
||||
loop.close()
|
||||
if self._loop:
|
||||
self._loop.close()
|
||||
self._loop = None
|
||||
|
||||
async def _connection_handler(self):
|
||||
"""处理WebSocket连接和重连逻辑"""
|
||||
@@ -466,8 +477,10 @@ class MessageProcessor:
|
||||
async with websockets.connect(
|
||||
self.websocket_url,
|
||||
ssl=ssl_context,
|
||||
open_timeout=20,
|
||||
ping_interval=WSConfig.ping_interval,
|
||||
ping_timeout=10,
|
||||
close_timeout=5,
|
||||
additional_headers={
|
||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||
"EdgeSession": f"{self.session_id}",
|
||||
@@ -478,81 +491,94 @@ class MessageProcessor:
|
||||
self.connected = True
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
|
||||
|
||||
# 启动发送协程
|
||||
send_task = asyncio.create_task(self._send_handler())
|
||||
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
|
||||
|
||||
# 每次连接(含重连)后重新向服务端注册,
|
||||
# 否则服务端不知道客户端已上线,不会推送消息。
|
||||
if self.websocket_client:
|
||||
self.websocket_client.publish_host_ready()
|
||||
|
||||
try:
|
||||
# 接收消息循环
|
||||
await self._message_handler()
|
||||
finally:
|
||||
# 必须在 async with __aexit__ 之前停止 send_task,
|
||||
# 否则 send_task 会在关闭握手期间继续发送数据,
|
||||
# 干扰 websockets 库的内部清理,导致 task 泄漏。
|
||||
self.connected = False
|
||||
send_task.cancel()
|
||||
try:
|
||||
await send_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.connected = False
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[MessageProcessor] Connection closed")
|
||||
self.connected = False
|
||||
logger.warning("[MessageProcessor] 与服务端连接中断")
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
|
||||
)
|
||||
except websockets.exceptions.InvalidStatus as e:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
self.connected = False
|
||||
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
|
||||
finally:
|
||||
self.connected = False
|
||||
self.websocket = None
|
||||
|
||||
# 重连逻辑
|
||||
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
if not self.is_running:
|
||||
break
|
||||
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
self.reconnect_count += 1
|
||||
backoff = WSConfig.reconnect_interval
|
||||
logger.info(
|
||||
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
|
||||
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
)
|
||||
await asyncio.sleep(WSConfig.reconnect_interval)
|
||||
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
||||
await asyncio.sleep(backoff)
|
||||
else:
|
||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||
break
|
||||
else:
|
||||
self.reconnect_count -= 1
|
||||
|
||||
async def _message_handler(self):
|
||||
"""处理接收到的消息"""
|
||||
"""处理接收到的消息。
|
||||
|
||||
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler,
|
||||
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
|
||||
正确清理内部 task,避免 task 泄漏。
|
||||
"""
|
||||
if not self.websocket:
|
||||
logger.error("[MessageProcessor] WebSocket connection is None")
|
||||
return
|
||||
|
||||
try:
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("[MessageProcessor] Message handler stopped - connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def _send_handler(self):
|
||||
"""处理发送队列中的消息"""
|
||||
@@ -601,6 +627,7 @@ class MessageProcessor:
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("[MessageProcessor] Send handler cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -632,6 +659,10 @@ class MessageProcessor:
|
||||
# elif message_type == "session_id":
|
||||
# self.session_id = message_data.get("session_id")
|
||||
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "add_device":
|
||||
await self._handle_device_manage(message_data, "add")
|
||||
elif message_type == "remove_device":
|
||||
await self._handle_device_manage(message_data, "remove")
|
||||
elif message_type == "request_restart":
|
||||
await self._handle_request_restart(message_data)
|
||||
else:
|
||||
@@ -723,6 +754,32 @@ class MessageProcessor:
|
||||
req = JobAddReq(**data)
|
||||
|
||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||
|
||||
# 服务端对always_free动作可能跳过query_action_state直接发job_start,
|
||||
# 此时job尚未注册,需要自动补注册
|
||||
existing_job = self.device_manager.get_job_info(req.job_id)
|
||||
if not existing_job:
|
||||
action_name = req.action
|
||||
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
||||
action_always_free = self._check_action_always_free(req.device_id, action_name)
|
||||
|
||||
if action_always_free:
|
||||
job_info = JobInfo(
|
||||
job_id=req.job_id,
|
||||
task_id=req.task_id,
|
||||
device_id=req.device_id,
|
||||
action_name=action_name,
|
||||
device_action_key=device_action_key,
|
||||
status=JobStatus.QUEUE,
|
||||
start_time=time.time(),
|
||||
always_free=True,
|
||||
)
|
||||
self.device_manager.add_queue_request(job_info)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
|
||||
else:
|
||||
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
|
||||
return
|
||||
|
||||
success = self.device_manager.start_job(req.job_id)
|
||||
if not success:
|
||||
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
|
||||
@@ -968,6 +1025,37 @@ class MessageProcessor:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_device_manage(self, device_list: list[ResourceDictType], action: str):
|
||||
"""Handle add_device / remove_device from LabGo server."""
|
||||
if not device_list:
|
||||
return
|
||||
|
||||
for item in device_list:
|
||||
target_node_id = item.get("target_node_id", "host_node")
|
||||
|
||||
def _notify(target_id: str, act: str, cfg: ResourceDictType):
|
||||
try:
|
||||
host_node = HostNode.get_instance(timeout=5)
|
||||
if not host_node:
|
||||
logger.error(f"[DeviceManage] HostNode not available for {act}_device")
|
||||
return
|
||||
success = host_node.notify_device_manage(target_id, act, cfg)
|
||||
if success:
|
||||
logger.info(f"[DeviceManage] {act}_device completed on {target_id}")
|
||||
else:
|
||||
logger.warning(f"[DeviceManage] {act}_device failed on {target_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DeviceManage] Error in {act}_device: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_notify,
|
||||
args=(target_node_id, action, item),
|
||||
daemon=True,
|
||||
name=f"DeviceManage-{action}-{item.get('id', '')}",
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理重启请求
|
||||
@@ -979,10 +1067,9 @@ class MessageProcessor:
|
||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||
|
||||
# 发送确认消息
|
||||
if self.websocket_client:
|
||||
await self.websocket_client.send_message(
|
||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||
)
|
||||
self.send_message(
|
||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||
)
|
||||
|
||||
# 设置全局重启标志
|
||||
import unilabos.app.main as main_module
|
||||
@@ -1084,6 +1171,7 @@ class QueueProcessor:
|
||||
def stop(self) -> None:
|
||||
"""停止队列处理线程"""
|
||||
self.is_running = False
|
||||
self.queue_update_event.set() # 立即唤醒等待中的线程
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2)
|
||||
logger.info("[QueueProcessor] Stopped")
|
||||
@@ -1337,8 +1425,8 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||
# 给一点时间让消息发送出去
|
||||
time.sleep(1)
|
||||
# send_handler 每100ms检查一次队列,等300ms足以让消息发出
|
||||
time.sleep(0.3)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from .separate_protocol import generate_separate_protocol
|
||||
from .evaporate_protocol import generate_evaporate_protocol
|
||||
from .evacuateandrefill_protocol import generate_evacuateandrefill_protocol
|
||||
from .agv_transfer_protocol import generate_agv_transfer_protocol
|
||||
from .batch_transfer_protocol import generate_batch_transfer_protocol
|
||||
from .add_protocol import generate_add_protocol
|
||||
from .centrifuge_protocol import generate_centrifuge_protocol
|
||||
from .filter_protocol import generate_filter_protocol
|
||||
@@ -31,6 +32,7 @@ from .hydrogenate_protocol import generate_hydrogenate_protocol
|
||||
action_protocol_generators = {
|
||||
AddProtocol: generate_add_protocol,
|
||||
AGVTransferProtocol: generate_agv_transfer_protocol,
|
||||
BatchTransferProtocol: generate_batch_transfer_protocol,
|
||||
AdjustPHProtocol: generate_adjust_ph_protocol,
|
||||
CentrifugeProtocol: generate_centrifuge_protocol,
|
||||
CleanProtocol: generate_clean_protocol,
|
||||
|
||||
127
unilabos/compile/_agv_utils.py
Normal file
127
unilabos/compile/_agv_utils.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
AGV 编译器共用工具函数
|
||||
|
||||
从 physical_setup_graph 中发现 AGV 节点配置,
|
||||
供 agv_transfer_protocol 和 batch_transfer_protocol 复用。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def find_agv_config(G: nx.Graph, agv_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""从设备图中发现 AGV 节点,返回其配置
|
||||
|
||||
查找策略:
|
||||
1. 如果指定 agv_id,直接读取该节点
|
||||
2. 否则查找 class 为 "agv_transport_station" 的节点
|
||||
3. 兜底查找 config 中包含 device_roles 的 workstation 节点
|
||||
|
||||
Returns:
|
||||
{
|
||||
"agv_id": str,
|
||||
"device_roles": {"navigator": "...", "arm": "..."},
|
||||
"route_table": {"A->B": {"nav_command": ..., "arm_pick": ..., "arm_place": ...}},
|
||||
"capacity": int,
|
||||
}
|
||||
"""
|
||||
if agv_id and agv_id in G.nodes:
|
||||
node_data = G.nodes[agv_id]
|
||||
config = _extract_config(node_data)
|
||||
if config and "device_roles" in config:
|
||||
return _build_agv_cfg(agv_id, config, G)
|
||||
|
||||
# 查找 agv_transport_station 类型
|
||||
for nid, ndata in G.nodes(data=True):
|
||||
node_class = _get_node_class(ndata)
|
||||
if node_class == "agv_transport_station":
|
||||
config = _extract_config(ndata)
|
||||
return _build_agv_cfg(nid, config or {}, G)
|
||||
|
||||
# 兜底:查找带有 device_roles 的 workstation
|
||||
for nid, ndata in G.nodes(data=True):
|
||||
node_class = _get_node_class(ndata)
|
||||
if node_class == "workstation":
|
||||
config = _extract_config(ndata)
|
||||
if config and "device_roles" in config:
|
||||
return _build_agv_cfg(nid, config, G)
|
||||
|
||||
raise ValueError("设备图中未找到 AGV 节点(需 class=agv_transport_station 或 config.device_roles)")
|
||||
|
||||
|
||||
def get_agv_capacity(G: nx.Graph, agv_id: str) -> int:
|
||||
"""从 AGV 的 Warehouse 子节点计算载具容量"""
|
||||
for neighbor in G.successors(agv_id) if G.is_directed() else G.neighbors(agv_id):
|
||||
ndata = G.nodes[neighbor]
|
||||
node_type = _get_node_type(ndata)
|
||||
if node_type == "warehouse":
|
||||
config = _extract_config(ndata)
|
||||
if config:
|
||||
x = config.get("num_items_x", 1)
|
||||
y = config.get("num_items_y", 1)
|
||||
z = config.get("num_items_z", 1)
|
||||
return x * y * z
|
||||
# 如果没有 warehouse 子节点,尝试从配置中读取
|
||||
return 0
|
||||
|
||||
|
||||
def split_batches(items: list, capacity: int) -> List[list]:
|
||||
"""按 AGV 容量分批
|
||||
|
||||
Args:
|
||||
items: 待转运的物料列表
|
||||
capacity: AGV 单批次容量
|
||||
|
||||
Returns:
|
||||
分批后的列表的列表
|
||||
"""
|
||||
if capacity <= 0:
|
||||
raise ValueError(f"AGV 容量必须 > 0,当前: {capacity}")
|
||||
return [items[i:i + capacity] for i in range(0, len(items), capacity)]
|
||||
|
||||
|
||||
def _extract_config(node_data: dict) -> Optional[dict]:
|
||||
"""从节点数据中提取 config 字段,兼容多种格式"""
|
||||
# 直接 config 字段
|
||||
config = node_data.get("config")
|
||||
if isinstance(config, dict):
|
||||
return config
|
||||
# res_content 嵌套格式
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "config"):
|
||||
return res_content.config if isinstance(res_content.config, dict) else None
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("config")
|
||||
return None
|
||||
|
||||
|
||||
def _get_node_class(node_data: dict) -> str:
|
||||
"""获取节点的 class 字段"""
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "model_dump"):
|
||||
d = res_content.model_dump()
|
||||
return d.get("class_", d.get("class", ""))
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("class_", res_content.get("class", ""))
|
||||
return node_data.get("class_", node_data.get("class", ""))
|
||||
|
||||
|
||||
def _get_node_type(node_data: dict) -> str:
|
||||
"""获取节点的 type 字段"""
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "type"):
|
||||
return res_content.type or ""
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("type", "")
|
||||
return node_data.get("type", "")
|
||||
|
||||
|
||||
def _build_agv_cfg(agv_id: str, config: dict, G: nx.Graph) -> Dict[str, Any]:
|
||||
"""构建标准化的 AGV 配置"""
|
||||
return {
|
||||
"agv_id": agv_id,
|
||||
"device_roles": config.get("device_roles", {}),
|
||||
"route_table": config.get("route_table", {}),
|
||||
"capacity": get_agv_capacity(G, agv_id),
|
||||
}
|
||||
@@ -2,20 +2,13 @@ from functools import partial
|
||||
|
||||
import networkx as nx
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Any, Union
|
||||
|
||||
from .utils.unit_parser import parse_volume_input, parse_mass_input, parse_time_input
|
||||
from .utils.vessel_parser import get_vessel, find_solid_dispenser, find_connected_stirrer, find_reagent_vessel
|
||||
from .utils.logger_util import action_log
|
||||
from .utils.logger_util import action_log, debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[ADD] {message}")
|
||||
|
||||
|
||||
# 🆕 创建进度日志动作
|
||||
create_action_log = partial(action_log, prefix="[ADD]")
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
from functools import partial
|
||||
|
||||
import networkx as nx
|
||||
import logging
|
||||
from typing import List, Dict, Any, Union
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.vessel_parser import get_vessel, find_connected_stirrer
|
||||
from .utils.logger_util import action_log, debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[ADJUST_PH] {message}")
|
||||
create_action_log = partial(action_log, prefix="[ADJUST_PH]")
|
||||
|
||||
def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
"""
|
||||
@@ -21,8 +19,6 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
Returns:
|
||||
str: 试剂容器ID
|
||||
"""
|
||||
debug_print(f"🔍 正在查找试剂 '{reagent}' 的容器...")
|
||||
|
||||
# 常见酸碱试剂的别名映射
|
||||
reagent_aliases = {
|
||||
"hydrochloric acid": ["HCl", "hydrochloric_acid", "hcl", "muriatic_acid"],
|
||||
@@ -36,17 +32,13 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
|
||||
# 构建搜索名称列表
|
||||
search_names = [reagent.lower()]
|
||||
debug_print(f"📋 基础搜索名称: {reagent.lower()}")
|
||||
|
||||
|
||||
# 添加别名
|
||||
for base_name, aliases in reagent_aliases.items():
|
||||
if reagent.lower() in base_name.lower() or base_name.lower() in reagent.lower():
|
||||
search_names.extend([alias.lower() for alias in aliases])
|
||||
debug_print(f"🔗 添加别名: {aliases}")
|
||||
break
|
||||
|
||||
debug_print(f"📝 完整搜索列表: {search_names}")
|
||||
|
||||
# 构建可能的容器名称
|
||||
possible_names = []
|
||||
for name in search_names:
|
||||
@@ -61,17 +53,15 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
name_clean
|
||||
])
|
||||
|
||||
debug_print(f"🎯 可能的容器名称 (前5个): {possible_names[:5]}... (共{len(possible_names)}个)")
|
||||
|
||||
debug_print(f"搜索容器: {len(possible_names)} 个候选名称")
|
||||
|
||||
# 第一步:通过容器名称匹配
|
||||
debug_print(f"📋 方法1: 精确名称匹配...")
|
||||
for vessel_name in possible_names:
|
||||
if vessel_name in G.nodes():
|
||||
debug_print(f"✅ 通过名称匹配找到容器: {vessel_name} 🎯")
|
||||
debug_print(f"通过名称匹配找到容器: {vessel_name}")
|
||||
return vessel_name
|
||||
|
||||
|
||||
# 第二步:通过模糊匹配
|
||||
debug_print(f"📋 方法2: 模糊名称匹配...")
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
node_name = G.nodes[node_id].get('name', '').lower()
|
||||
@@ -79,11 +69,10 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
# 检查是否包含任何搜索名称
|
||||
for search_name in search_names:
|
||||
if search_name in node_id.lower() or search_name in node_name:
|
||||
debug_print(f"✅ 通过模糊匹配找到容器: {node_id} 🔍")
|
||||
debug_print(f"通过模糊匹配找到容器: {node_id}")
|
||||
return node_id
|
||||
|
||||
|
||||
# 第三步:通过液体类型匹配
|
||||
debug_print(f"📋 方法3: 液体类型匹配...")
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
@@ -96,56 +85,15 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
|
||||
for search_name in search_names:
|
||||
if search_name in liquid_type or search_name in reagent_name:
|
||||
debug_print(f"✅ 通过液体类型匹配找到容器: {node_id} 💧")
|
||||
debug_print(f"通过液体类型匹配找到容器: {node_id}")
|
||||
return node_id
|
||||
|
||||
# 列出可用容器帮助调试
|
||||
debug_print(f"📊 列出可用容器帮助调试...")
|
||||
available_containers = []
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
liquid_types = [liquid.get('liquid_type', '') or liquid.get('name', '')
|
||||
for liquid in liquids if isinstance(liquid, dict)]
|
||||
|
||||
available_containers.append({
|
||||
'id': node_id,
|
||||
'name': G.nodes[node_id].get('name', ''),
|
||||
'liquids': liquid_types,
|
||||
'reagent_name': vessel_data.get('reagent_name', '')
|
||||
})
|
||||
|
||||
debug_print(f"📋 可用容器列表:")
|
||||
for container in available_containers:
|
||||
debug_print(f" - 🧪 {container['id']}: {container['name']}")
|
||||
debug_print(f" 💧 液体: {container['liquids']}")
|
||||
debug_print(f" 🏷️ 试剂: {container['reagent_name']}")
|
||||
|
||||
debug_print(f"❌ 所有匹配方法都失败了")
|
||||
available_containers = [node_id for node_id in G.nodes()
|
||||
if G.nodes[node_id].get('type') == 'container']
|
||||
debug_print(f"所有匹配方法失败,可用容器: {available_containers}")
|
||||
raise ValueError(f"找不到试剂 '{reagent}' 对应的容器。尝试了: {possible_names[:10]}...")
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与容器相连的搅拌器"""
|
||||
debug_print(f"🔍 查找连接到容器 '{vessel}' 的搅拌器...")
|
||||
|
||||
stirrer_nodes = [node for node in G.nodes()
|
||||
if (G.nodes[node].get('class') or '') == 'virtual_stirrer']
|
||||
|
||||
debug_print(f"📊 发现 {len(stirrer_nodes)} 个搅拌器: {stirrer_nodes}")
|
||||
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 找到连接的搅拌器: {stirrer} 🔗")
|
||||
return stirrer
|
||||
|
||||
if stirrer_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的搅拌器,使用第一个: {stirrer_nodes[0]} 🔄")
|
||||
return stirrer_nodes[0]
|
||||
|
||||
debug_print(f"❌ 未找到任何搅拌器")
|
||||
return None
|
||||
|
||||
def calculate_reagent_volume(target_ph_value: float, reagent: str, vessel_volume: float = 100.0) -> float:
|
||||
"""
|
||||
估算需要的试剂体积来调节pH
|
||||
@@ -158,44 +106,30 @@ def calculate_reagent_volume(target_ph_value: float, reagent: str, vessel_volume
|
||||
Returns:
|
||||
float: 估算的试剂体积 (mL)
|
||||
"""
|
||||
debug_print(f"🧮 计算试剂体积...")
|
||||
debug_print(f" 📍 目标pH: {target_ph_value}")
|
||||
debug_print(f" 🧪 试剂: {reagent}")
|
||||
debug_print(f" 📏 容器体积: {vessel_volume}mL")
|
||||
|
||||
# 简化的pH调节体积估算(实际应用中需要更精确的计算)
|
||||
debug_print(f"计算试剂体积: pH={target_ph_value}, reagent={reagent}, vessel={vessel_volume}mL")
|
||||
|
||||
# 简化的pH调节体积估算
|
||||
if "acid" in reagent.lower() or "hcl" in reagent.lower():
|
||||
debug_print(f"🍋 检测到酸性试剂")
|
||||
# 酸性试剂:pH越低需要的体积越大
|
||||
if target_ph_value < 3:
|
||||
volume = vessel_volume * 0.05 # 5%
|
||||
debug_print(f" 💪 强酸性 (pH<3): 使用 5% 体积")
|
||||
volume = vessel_volume * 0.05
|
||||
elif target_ph_value < 5:
|
||||
volume = vessel_volume * 0.02 # 2%
|
||||
debug_print(f" 🔸 中酸性 (pH<5): 使用 2% 体积")
|
||||
volume = vessel_volume * 0.02
|
||||
else:
|
||||
volume = vessel_volume * 0.01 # 1%
|
||||
debug_print(f" 🔹 弱酸性 (pH≥5): 使用 1% 体积")
|
||||
|
||||
volume = vessel_volume * 0.01
|
||||
|
||||
elif "hydroxide" in reagent.lower() or "naoh" in reagent.lower():
|
||||
debug_print(f"🧂 检测到碱性试剂")
|
||||
# 碱性试剂:pH越高需要的体积越大
|
||||
if target_ph_value > 11:
|
||||
volume = vessel_volume * 0.05 # 5%
|
||||
debug_print(f" 💪 强碱性 (pH>11): 使用 5% 体积")
|
||||
volume = vessel_volume * 0.05
|
||||
elif target_ph_value > 9:
|
||||
volume = vessel_volume * 0.02 # 2%
|
||||
debug_print(f" 🔸 中碱性 (pH>9): 使用 2% 体积")
|
||||
volume = vessel_volume * 0.02
|
||||
else:
|
||||
volume = vessel_volume * 0.01 # 1%
|
||||
debug_print(f" 🔹 弱碱性 (pH≤9): 使用 1% 体积")
|
||||
|
||||
volume = vessel_volume * 0.01
|
||||
|
||||
else:
|
||||
# 未知试剂,使用默认值
|
||||
volume = vessel_volume * 0.01
|
||||
debug_print(f"❓ 未知试剂类型,使用默认 1% 体积")
|
||||
|
||||
debug_print(f"📊 计算结果: {volume:.2f}mL")
|
||||
|
||||
debug_print(f"估算试剂体积: {volume:.2f}mL")
|
||||
return volume
|
||||
|
||||
def generate_adjust_ph_protocol(
|
||||
@@ -220,96 +154,67 @@ def generate_adjust_ph_protocol(
|
||||
"""
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
if not vessel_id:
|
||||
debug_print(f"❌ vessel 参数无效,必须包含id字段或直接提供容器ID. vessel: {vessel}")
|
||||
raise ValueError("vessel 参数无效,必须包含id字段或直接提供容器ID")
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print("🧪 开始生成pH调节协议")
|
||||
debug_print(f"📋 原始参数:")
|
||||
debug_print(f" 🥼 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 📊 ph_value: {ph_value}")
|
||||
debug_print(f" 🧪 reagent: '{reagent}'")
|
||||
debug_print(f" 📦 kwargs: {kwargs}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
|
||||
debug_print(f"pH调节协议: vessel={vessel_id}, ph={ph_value}, reagent='{reagent}'")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 从kwargs中获取可选参数,如果没有则使用默认值
|
||||
volume = kwargs.get('volume', 0.0) # 自动估算体积
|
||||
stir = kwargs.get('stir', True) # 默认搅拌
|
||||
stir_speed = kwargs.get('stir_speed', 300.0) # 默认搅拌速度
|
||||
stir_time = kwargs.get('stir_time', 60.0) # 默认搅拌时间
|
||||
settling_time = kwargs.get('settling_time', 30.0) # 默认平衡时间
|
||||
|
||||
debug_print(f"🔧 处理后的参数:")
|
||||
debug_print(f" 📏 volume: {volume}mL (0.0表示自动估算)")
|
||||
debug_print(f" 🌪️ stir: {stir}")
|
||||
debug_print(f" 🔄 stir_speed: {stir_speed}rpm")
|
||||
debug_print(f" ⏱️ stir_time: {stir_time}s")
|
||||
debug_print(f" ⏳ settling_time: {settling_time}s")
|
||||
|
||||
|
||||
# 从kwargs中获取可选参数
|
||||
volume = kwargs.get('volume', 0.0)
|
||||
stir = kwargs.get('stir', True)
|
||||
stir_speed = kwargs.get('stir_speed', 300.0)
|
||||
stir_time = kwargs.get('stir_time', 60.0)
|
||||
settling_time = kwargs.get('settling_time', 30.0)
|
||||
|
||||
# 开始处理
|
||||
action_sequence.append(create_action_log(f"开始调节pH至 {ph_value}", "🧪"))
|
||||
action_sequence.append(create_action_log(f"目标容器: {vessel_id}", "🥼"))
|
||||
action_sequence.append(create_action_log(f"使用试剂: {reagent}", "⚗️"))
|
||||
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
debug_print(f"🔍 步骤1: 验证目标容器...")
|
||||
if vessel_id not in G.nodes():
|
||||
debug_print(f"❌ 目标容器 '{vessel_id}' 不存在于系统中")
|
||||
raise ValueError(f"目标容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print(f"✅ 目标容器验证通过")
|
||||
|
||||
action_sequence.append(create_action_log("目标容器验证通过", "✅"))
|
||||
|
||||
|
||||
# 2. 查找酸碱试剂容器
|
||||
debug_print(f"🔍 步骤2: 查找试剂容器...")
|
||||
action_sequence.append(create_action_log("正在查找试剂容器...", "🔍"))
|
||||
|
||||
try:
|
||||
reagent_vessel = find_acid_base_vessel(G, reagent)
|
||||
debug_print(f"✅ 找到试剂容器: {reagent_vessel}")
|
||||
action_sequence.append(create_action_log(f"找到试剂容器: {reagent_vessel}", "🧪"))
|
||||
except ValueError as e:
|
||||
debug_print(f"❌ 无法找到试剂容器: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"试剂容器查找失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"无法找到试剂 '{reagent}': {str(e)}")
|
||||
|
||||
|
||||
# 3. 体积估算
|
||||
debug_print(f"🔍 步骤3: 体积处理...")
|
||||
if volume <= 0:
|
||||
action_sequence.append(create_action_log("开始自动估算试剂体积", "🧮"))
|
||||
|
||||
# 获取目标容器的体积信息
|
||||
vessel_data = G.nodes[vessel_id].get('data', {})
|
||||
vessel_volume = vessel_data.get('max_volume', 100.0) # 默认100mL
|
||||
debug_print(f"📏 容器最大体积: {vessel_volume}mL")
|
||||
|
||||
vessel_volume = vessel_data.get('max_volume', 100.0)
|
||||
|
||||
estimated_volume = calculate_reagent_volume(ph_value, reagent, vessel_volume)
|
||||
volume = estimated_volume
|
||||
debug_print(f"✅ 自动估算试剂体积: {volume:.2f} mL")
|
||||
action_sequence.append(create_action_log(f"估算试剂体积: {volume:.2f}mL", "📊"))
|
||||
else:
|
||||
debug_print(f"📏 使用指定体积: {volume}mL")
|
||||
action_sequence.append(create_action_log(f"使用指定体积: {volume}mL", "📏"))
|
||||
|
||||
|
||||
# 4. 验证路径存在
|
||||
debug_print(f"🔍 步骤4: 路径验证...")
|
||||
action_sequence.append(create_action_log("验证转移路径...", "🛤️"))
|
||||
|
||||
try:
|
||||
path = nx.shortest_path(G, source=reagent_vessel, target=vessel_id)
|
||||
debug_print(f"✅ 找到路径: {' → '.join(path)}")
|
||||
action_sequence.append(create_action_log(f"找到转移路径: {' → '.join(path)}", "🛤️"))
|
||||
action_sequence.append(create_action_log(f"找到转移路径: {' -> '.join(path)}", "🛤️"))
|
||||
except nx.NetworkXNoPath:
|
||||
debug_print(f"❌ 无法找到转移路径")
|
||||
action_sequence.append(create_action_log("转移路径不存在", "❌"))
|
||||
raise ValueError(f"从试剂容器 '{reagent_vessel}' 到目标容器 '{vessel_id}' 没有可用路径")
|
||||
|
||||
|
||||
# 5. 搅拌器设置
|
||||
debug_print(f"🔍 步骤5: 搅拌器设置...")
|
||||
stirrer_id = None
|
||||
if stir:
|
||||
action_sequence.append(create_action_log("准备启动搅拌器", "🌪️"))
|
||||
@@ -318,7 +223,6 @@ def generate_adjust_ph_protocol(
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
|
||||
if stirrer_id:
|
||||
debug_print(f"✅ 找到搅拌器 {stirrer_id},启动搅拌")
|
||||
action_sequence.append(create_action_log(f"启动搅拌器 {stirrer_id} (速度: {stir_speed}rpm)", "🔄"))
|
||||
|
||||
action_sequence.append({
|
||||
@@ -338,23 +242,18 @@ def generate_adjust_ph_protocol(
|
||||
"action_kwargs": {"time": 5}
|
||||
})
|
||||
else:
|
||||
debug_print(f"⚠️ 未找到搅拌器,继续执行")
|
||||
action_sequence.append(create_action_log("未找到搅拌器,跳过搅拌", "⚠️"))
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 搅拌器配置出错: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"搅拌器配置失败: {str(e)}", "❌"))
|
||||
else:
|
||||
debug_print(f"📋 跳过搅拌设置")
|
||||
action_sequence.append(create_action_log("跳过搅拌设置", "⏭️"))
|
||||
|
||||
|
||||
# 6. 试剂添加
|
||||
debug_print(f"🔍 步骤6: 试剂添加...")
|
||||
action_sequence.append(create_action_log(f"开始添加试剂 {volume:.2f}mL", "🚰"))
|
||||
|
||||
# 计算添加时间(pH调节需要缓慢添加)
|
||||
addition_time = max(30.0, volume * 2.0) # 至少30秒,每mL需要2秒
|
||||
debug_print(f"⏱️ 计算添加时间: {addition_time}s (缓慢注入)")
|
||||
addition_time = max(30.0, volume * 2.0)
|
||||
action_sequence.append(create_action_log(f"设置添加时间: {addition_time:.0f}s (缓慢注入)", "⏱️"))
|
||||
|
||||
try:
|
||||
@@ -377,35 +276,28 @@ def generate_adjust_ph_protocol(
|
||||
)
|
||||
|
||||
action_sequence.extend(pump_actions)
|
||||
debug_print(f"✅ 泵协议生成完成,添加了 {len(pump_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"试剂转移完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
|
||||
# 🔧 修复体积运算 - 试剂添加成功后更新容器液体体积
|
||||
debug_print(f"🔧 更新容器液体体积...")
|
||||
|
||||
# 体积运算 - 试剂添加成功后更新容器液体体积
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
debug_print(f"📊 添加前容器体积: {current_volume}")
|
||||
|
||||
|
||||
# 处理不同的体积数据格式
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
# 增加体积(添加试剂)
|
||||
vessel["data"]["liquid_volume"][0] += volume
|
||||
debug_print(f"📊 添加后容器体积: {vessel['data']['liquid_volume'][0]:.2f}mL (+{volume:.2f}mL)")
|
||||
else:
|
||||
# 如果列表为空,创建新的体积记录
|
||||
vessel["data"]["liquid_volume"] = [volume]
|
||||
debug_print(f"📊 初始化容器体积: {volume:.2f}mL")
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
# 直接数值类型
|
||||
vessel["data"]["liquid_volume"] += volume
|
||||
debug_print(f"📊 添加后容器体积: {vessel['data']['liquid_volume']:.2f}mL (+{volume:.2f}mL)")
|
||||
else:
|
||||
debug_print(f"⚠️ 未知的体积数据格式: {type(current_volume)}")
|
||||
debug_print(f"未知的体积数据格式: {type(current_volume)}")
|
||||
# 创建新的体积记录
|
||||
vessel["data"]["liquid_volume"] = volume
|
||||
else:
|
||||
debug_print(f"📊 容器无液体体积数据,创建新记录: {volume:.2f}mL")
|
||||
# 确保vessel有data字段
|
||||
if "data" not in vessel:
|
||||
vessel["data"] = {}
|
||||
@@ -423,19 +315,16 @@ def generate_adjust_ph_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = current_node_volume + volume
|
||||
|
||||
debug_print(f"✅ 图节点体积数据已更新")
|
||||
|
||||
|
||||
action_sequence.append(create_action_log(f"容器体积已更新 (+{volume:.2f}mL)", "📊"))
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 生成泵协议时出错: {str(e)}")
|
||||
debug_print(f"生成泵协议时出错: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"泵协议生成失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"生成泵协议时出错: {str(e)}")
|
||||
|
||||
# 7. 混合搅拌
|
||||
if stir and stirrer_id:
|
||||
debug_print(f"🔍 步骤7: 混合搅拌...")
|
||||
action_sequence.append(create_action_log(f"开始混合搅拌 {stir_time:.0f}s", "🌀"))
|
||||
|
||||
action_sequence.append({
|
||||
@@ -448,14 +337,10 @@ def generate_adjust_ph_protocol(
|
||||
"purpose": f"pH调节: 混合试剂,目标pH={ph_value}"
|
||||
}
|
||||
})
|
||||
|
||||
debug_print(f"✅ 混合搅拌设置完成")
|
||||
else:
|
||||
debug_print(f"⏭️ 跳过混合搅拌")
|
||||
action_sequence.append(create_action_log("跳过混合搅拌", "⏭️"))
|
||||
|
||||
|
||||
# 8. 等待平衡
|
||||
debug_print(f"🔍 步骤8: 反应平衡...")
|
||||
action_sequence.append(create_action_log(f"等待pH平衡 {settling_time:.0f}s", "⚖️"))
|
||||
|
||||
action_sequence.append({
|
||||
@@ -468,17 +353,7 @@ def generate_adjust_ph_protocol(
|
||||
|
||||
# 9. 完成总结
|
||||
total_time = addition_time + stir_time + settling_time
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 pH调节协议生成完成")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)}")
|
||||
debug_print(f" ⏱️ 预计总时间: {total_time:.0f}s ({total_time/60:.1f}分钟)")
|
||||
debug_print(f" 🧪 试剂: {reagent}")
|
||||
debug_print(f" 📏 体积: {volume:.2f}mL")
|
||||
debug_print(f" 📊 目标pH: {ph_value}")
|
||||
debug_print(f" 🥼 目标容器: {vessel_id}")
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"pH调节协议完成: {len(action_sequence)} 个动作, {total_time:.0f}s, {volume:.2f}mL {reagent} → {vessel_id} pH {ph_value}")
|
||||
|
||||
# 添加完成日志
|
||||
summary_msg = f"pH调节协议完成: {vessel_id} → pH {ph_value} (使用 {volume:.2f}mL {reagent})"
|
||||
@@ -510,28 +385,18 @@ def generate_adjust_ph_protocol_stepwise(
|
||||
"""
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id = vessel["id"]
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🔄 开始分步pH调节")
|
||||
debug_print(f"📋 分步参数:")
|
||||
debug_print(f" 🥼 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 📊 ph_value: {ph_value}")
|
||||
debug_print(f" 🧪 reagent: {reagent}")
|
||||
debug_print(f" 📏 max_volume: {max_volume}mL")
|
||||
debug_print(f" 🔢 steps: {steps}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
debug_print(f"分步pH调节: vessel={vessel_id}, ph={ph_value}, reagent={reagent}, max_volume={max_volume}mL, steps={steps}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 每步添加的体积
|
||||
step_volume = max_volume / steps
|
||||
debug_print(f"📊 每步体积: {step_volume:.2f}mL")
|
||||
|
||||
action_sequence.append(create_action_log(f"开始分步pH调节 ({steps}步)", "🔄"))
|
||||
action_sequence.append(create_action_log(f"每步添加: {step_volume:.2f}mL", "📏"))
|
||||
|
||||
for i in range(steps):
|
||||
debug_print(f"🔄 执行第 {i+1}/{steps} 步,添加 {step_volume:.2f}mL")
|
||||
action_sequence.append(create_action_log(f"第 {i+1}/{steps} 步开始", "🚀"))
|
||||
|
||||
# 生成单步协议
|
||||
@@ -548,12 +413,10 @@ def generate_adjust_ph_protocol_stepwise(
|
||||
)
|
||||
|
||||
action_sequence.extend(step_actions)
|
||||
debug_print(f"✅ 第 {i+1}/{steps} 步完成,添加了 {len(step_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"第 {i+1}/{steps} 步完成", "✅"))
|
||||
|
||||
# 步骤间等待
|
||||
if i < steps - 1:
|
||||
debug_print(f"⏳ 步骤间等待30s")
|
||||
action_sequence.append(create_action_log("步骤间等待...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
@@ -563,7 +426,7 @@ def generate_adjust_ph_protocol_stepwise(
|
||||
}
|
||||
})
|
||||
|
||||
debug_print(f"🎉 分步pH调节完成,共 {len(action_sequence)} 个动作")
|
||||
debug_print(f"分步pH调节完成: {len(action_sequence)} 个动作")
|
||||
action_sequence.append(create_action_log("分步pH调节全部完成", "🎉"))
|
||||
|
||||
return action_sequence
|
||||
@@ -577,7 +440,7 @@ def generate_acidify_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""酸化协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🍋 生成酸化协议: {vessel_id} → pH {target_ph} (使用 {acid})")
|
||||
debug_print(f"酸化协议: {vessel_id} → pH {target_ph} ({acid})")
|
||||
return generate_adjust_ph_protocol(
|
||||
G, vessel, target_ph, acid
|
||||
)
|
||||
@@ -590,7 +453,7 @@ def generate_basify_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""碱化协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🧂 生成碱化协议: {vessel_id} → pH {target_ph} (使用 {base})")
|
||||
debug_print(f"碱化协议: {vessel_id} → pH {target_ph} ({base})")
|
||||
return generate_adjust_ph_protocol(
|
||||
G, vessel, target_ph, base
|
||||
)
|
||||
@@ -602,7 +465,7 @@ def generate_neutralize_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""中和协议(pH=7)"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"⚖️ 生成中和协议: {vessel_id} → pH 7.0 (使用 {reagent})")
|
||||
debug_print(f"中和协议: {vessel_id} → pH 7.0 ({reagent})")
|
||||
return generate_adjust_ph_protocol(
|
||||
G, vessel, 7.0, reagent
|
||||
)
|
||||
@@ -610,10 +473,7 @@ def generate_neutralize_protocol(
|
||||
# 测试函数
|
||||
def test_adjust_ph_protocol():
|
||||
"""测试pH调节协议"""
|
||||
debug_print("=== ADJUST PH PROTOCOL 增强版测试 ===")
|
||||
|
||||
# 测试体积计算
|
||||
debug_print("🧮 测试体积计算...")
|
||||
test_cases = [
|
||||
(2.0, "hydrochloric acid", 100.0),
|
||||
(4.0, "hydrochloric acid", 100.0),
|
||||
@@ -621,12 +481,12 @@ def test_adjust_ph_protocol():
|
||||
(10.0, "sodium hydroxide", 100.0),
|
||||
(7.0, "unknown reagent", 100.0)
|
||||
]
|
||||
|
||||
|
||||
for ph, reagent, volume in test_cases:
|
||||
result = calculate_reagent_volume(ph, reagent, volume)
|
||||
debug_print(f"📊 {reagent} → pH {ph}: {result:.2f}mL")
|
||||
|
||||
debug_print("✅ 测试完成")
|
||||
debug_print(f"{reagent} → pH {ph}: {result:.2f}mL")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_adjust_ph_protocol()
|
||||
@@ -1,4 +1,12 @@
|
||||
"""
|
||||
AGV 单物料转运编译器
|
||||
|
||||
从 physical_setup_graph 中查询 AGV 配置(device_roles, route_table),
|
||||
不再硬编码 device_id 和路由表。
|
||||
"""
|
||||
|
||||
import networkx as nx
|
||||
from unilabos.compile._agv_utils import find_agv_config
|
||||
|
||||
|
||||
def generate_agv_transfer_protocol(
|
||||
@@ -17,37 +25,32 @@ def generate_agv_transfer_protocol(
|
||||
from_repo_id = from_repo_["id"]
|
||||
to_repo_id = to_repo_["id"]
|
||||
|
||||
wf_list = {
|
||||
("AiChemEcoHiWo", "zhixing_agv"): {"nav_command" : '{"target" : "LM14"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
|
||||
("AiChemEcoHiWo", "AGV"): {"nav_command" : '{"target" : "LM14"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
|
||||
# 从 G 中查询 AGV 配置
|
||||
agv_cfg = find_agv_config(G)
|
||||
device_roles = agv_cfg["device_roles"]
|
||||
route_table = agv_cfg["route_table"]
|
||||
|
||||
("zhixing_agv", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
|
||||
route_key = f"{from_repo_id}->{to_repo_id}"
|
||||
if route_key not in route_table:
|
||||
raise KeyError(f"AGV 路由表中未找到路线: {route_key},可用路线: {list(route_table.keys())}")
|
||||
|
||||
("AGV", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
|
||||
route = route_table[route_key]
|
||||
nav_device = device_roles.get("navigator", device_roles.get("nav"))
|
||||
arm_device = device_roles.get("arm")
|
||||
|
||||
("Revvity", "HPLC"): {"nav_command": '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_hplc.urp"}'},
|
||||
|
||||
("HPLC", "Revvity"): {"nav_command": '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_lfp.urp"}'},
|
||||
}
|
||||
return [
|
||||
{
|
||||
"device_id": "zhixing_agv",
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": wf_list[(from_repo_id, to_repo_id)]["nav_command"]
|
||||
"command": route["nav_command"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": "zhixing_ur_arm",
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": wf_list[(from_repo_id, to_repo_id)]["arm_command"]
|
||||
"command": route.get("arm_command", route.get("arm_place", ""))
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
228
unilabos/compile/batch_transfer_protocol.py
Normal file
228
unilabos/compile/batch_transfer_protocol.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
批量物料转运编译器
|
||||
|
||||
将 BatchTransferProtocol 编译为多批次的 nav → pick × N → nav → place × N 动作序列。
|
||||
自动按 AGV 容量分批,全程维护三方 children dict 的物料系统一致性。
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.compile._agv_utils import find_agv_config, split_batches
|
||||
|
||||
|
||||
def generate_batch_transfer_protocol(
|
||||
G: nx.Graph,
|
||||
from_repo: dict,
|
||||
to_repo: dict,
|
||||
transfer_resources: list,
|
||||
from_positions: list,
|
||||
to_positions: list,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""编译批量转运协议为可执行的 action steps
|
||||
|
||||
Args:
|
||||
G: 设备图 (physical_setup_graph)
|
||||
from_repo: 来源工站资源 dict({station_id: {..., children: {...}}})
|
||||
to_repo: 目标工站资源 dict(含堆栈和位置信息)
|
||||
transfer_resources: 被转运的物料列表(Resource dict)
|
||||
from_positions: 来源 slot 位置列表(与 transfer_resources 平行)
|
||||
to_positions: 目标 slot 位置列表(与 transfer_resources 平行)
|
||||
|
||||
Returns:
|
||||
action steps 列表,ROS2WorkstationNode 按序执行
|
||||
"""
|
||||
if not transfer_resources:
|
||||
return []
|
||||
|
||||
n = len(transfer_resources)
|
||||
if len(from_positions) != n or len(to_positions) != n:
|
||||
raise ValueError(
|
||||
f"transfer_resources({n}), from_positions({len(from_positions)}), "
|
||||
f"to_positions({len(to_positions)}) 长度不一致"
|
||||
)
|
||||
|
||||
# 组合为内部 transfer_items 便于分批处理
|
||||
transfer_items = []
|
||||
for i in range(n):
|
||||
res = transfer_resources[i] if isinstance(transfer_resources[i], dict) else {}
|
||||
transfer_items.append({
|
||||
"resource_id": res.get("id", res.get("name", "")),
|
||||
"resource_uuid": res.get("sample_id", ""),
|
||||
"from_position": from_positions[i],
|
||||
"to_position": to_positions[i],
|
||||
"resource": res,
|
||||
})
|
||||
|
||||
# 查询 AGV 配置
|
||||
agv_cfg = find_agv_config(G)
|
||||
agv_id = agv_cfg["agv_id"]
|
||||
device_roles = agv_cfg["device_roles"]
|
||||
route_table = agv_cfg["route_table"]
|
||||
capacity = agv_cfg["capacity"]
|
||||
|
||||
if capacity <= 0:
|
||||
raise ValueError(f"AGV {agv_id} 容量为 0,请检查 Warehouse 子节点配置")
|
||||
|
||||
nav_device = device_roles.get("navigator", device_roles.get("nav"))
|
||||
arm_device = device_roles.get("arm")
|
||||
if not nav_device or not arm_device:
|
||||
raise ValueError(f"AGV {agv_id} device_roles 缺少 navigator 或 arm: {device_roles}")
|
||||
|
||||
from_repo_ = list(from_repo.values())[0]
|
||||
to_repo_ = list(to_repo.values())[0]
|
||||
from_station_id = from_repo_["id"]
|
||||
to_station_id = to_repo_["id"]
|
||||
|
||||
# 查找路由
|
||||
route_to_source = _find_route(route_table, agv_id, from_station_id)
|
||||
route_to_target = _find_route(route_table, from_station_id, to_station_id)
|
||||
|
||||
# 构建 AGV carrier 的 children dict(用于 compile 阶段状态追踪)
|
||||
agv_carrier_children: Dict[str, Any] = {}
|
||||
|
||||
# 计算 slot 名称(A01, A02, B01, ...)
|
||||
agv_slot_names = _get_agv_slot_names(G, agv_cfg)
|
||||
|
||||
# 分批
|
||||
batches = split_batches(transfer_items, capacity)
|
||||
|
||||
steps: List[Dict[str, Any]] = []
|
||||
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
is_last_batch = (batch_idx == len(batches) - 1)
|
||||
|
||||
# 阶段 1: AGV 导航到来源工站
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"批次{batch_idx + 1}/{len(batches)}: AGV 导航至来源 {from_station_id}"
|
||||
})
|
||||
|
||||
# 阶段 2: 逐个 pick
|
||||
for item_idx, item in enumerate(batch):
|
||||
from_pos = item["from_position"]
|
||||
slot = agv_slot_names[item_idx] if item_idx < len(agv_slot_names) else f"S{item_idx + 1}"
|
||||
|
||||
# compile 阶段更新 children dict
|
||||
if from_pos in from_repo_.get("children", {}):
|
||||
resource_data = from_repo_["children"].pop(from_pos)
|
||||
resource_data["parent"] = agv_id
|
||||
agv_carrier_children[slot] = resource_data
|
||||
|
||||
steps.append({
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("arm_pick", route_to_source.get("arm_command", ""))
|
||||
},
|
||||
"_transfer_meta": {
|
||||
"phase": "pick",
|
||||
"resource_uuid": item.get("resource_uuid", ""),
|
||||
"resource_id": item.get("resource_id", ""),
|
||||
"from_parent": from_station_id,
|
||||
"from_position": from_pos,
|
||||
"agv_slot": slot,
|
||||
},
|
||||
"_comment": f"Pick {item.get('resource_id', from_pos)} → AGV.{slot}"
|
||||
})
|
||||
|
||||
# 阶段 3: AGV 导航到目标工站
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_target.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"批次{batch_idx + 1}: AGV 导航至目标 {to_station_id}"
|
||||
})
|
||||
|
||||
# 阶段 4: 逐个 place
|
||||
for item_idx, item in enumerate(batch):
|
||||
to_pos = item["to_position"]
|
||||
slot = agv_slot_names[item_idx] if item_idx < len(agv_slot_names) else f"S{item_idx + 1}"
|
||||
|
||||
# compile 阶段更新 children dict
|
||||
if slot in agv_carrier_children:
|
||||
resource_data = agv_carrier_children.pop(slot)
|
||||
resource_data["parent"] = to_repo_["id"]
|
||||
to_repo_["children"][to_pos] = resource_data
|
||||
|
||||
steps.append({
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_target.get("arm_place", route_to_target.get("arm_command", ""))
|
||||
},
|
||||
"_transfer_meta": {
|
||||
"phase": "place",
|
||||
"resource_uuid": item.get("resource_uuid", ""),
|
||||
"resource_id": item.get("resource_id", ""),
|
||||
"to_parent": to_station_id,
|
||||
"to_position": to_pos,
|
||||
"agv_slot": slot,
|
||||
},
|
||||
"_comment": f"Place AGV.{slot} → {to_station_id}.{to_pos}"
|
||||
})
|
||||
|
||||
# 如果还有下一批,AGV 需要返回来源取料
|
||||
if not is_last_batch:
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"AGV 返回来源 {from_station_id} 取下一批"
|
||||
})
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def _find_route(route_table: Dict[str, Any], from_id: str, to_id: str) -> Dict[str, str]:
|
||||
"""在路由表中查找路线,支持 A->B 和 (A, B) 两种 key 格式"""
|
||||
# 优先 "A->B" 格式
|
||||
key = f"{from_id}->{to_id}"
|
||||
if key in route_table:
|
||||
return route_table[key]
|
||||
# 兼容 tuple key(JSON 中以逗号分隔字符串表示)
|
||||
tuple_key = f"({from_id}, {to_id})"
|
||||
if tuple_key in route_table:
|
||||
return route_table[tuple_key]
|
||||
raise KeyError(f"路由表中未找到: {key},可用路线: {list(route_table.keys())}")
|
||||
|
||||
|
||||
def _get_agv_slot_names(G: nx.Graph, agv_cfg: dict) -> List[str]:
|
||||
"""从设备图中获取 AGV Warehouse 的 slot 名称列表"""
|
||||
agv_id = agv_cfg["agv_id"]
|
||||
neighbors = G.successors(agv_id) if G.is_directed() else G.neighbors(agv_id)
|
||||
for neighbor in neighbors:
|
||||
ndata = G.nodes[neighbor]
|
||||
node_type = ndata.get("type", "")
|
||||
res_content = ndata.get("res_content")
|
||||
if hasattr(res_content, "type"):
|
||||
node_type = res_content.type or node_type
|
||||
elif isinstance(res_content, dict):
|
||||
node_type = res_content.get("type", node_type)
|
||||
if node_type == "warehouse":
|
||||
config = ndata.get("config", {})
|
||||
if hasattr(res_content, "config") and isinstance(res_content.config, dict):
|
||||
config = res_content.config
|
||||
elif isinstance(res_content, dict):
|
||||
config = res_content.get("config", config)
|
||||
num_x = config.get("num_items_x", 1)
|
||||
num_y = config.get("num_items_y", 1)
|
||||
num_z = config.get("num_items_z", 1)
|
||||
# 与 warehouse_factory 一致的命名
|
||||
letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
len_x = num_x if num_z == 1 else (num_y if num_x == 1 else num_x)
|
||||
len_y = num_y if num_z == 1 else (num_z if num_x == 1 else num_z)
|
||||
return [f"{letters[j]}{i + 1:02d}" for i in range(len_x) for j in range(len_y)]
|
||||
# 兜底生成通用名称
|
||||
capacity = agv_cfg.get("capacity", 4)
|
||||
return [f"S{i + 1}" for i in range(capacity)]
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import List, Dict, Any
|
||||
import networkx as nx
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel, find_connected_heatchill
|
||||
from .utils.logger_util import debug_print
|
||||
from .pump_protocol import generate_pump_protocol
|
||||
from .utils.resource_helper import get_resource_liquid_volume
|
||||
|
||||
|
||||
def find_solvent_vessel_by_any_match(G: nx.DiGraph, solvent: str) -> str:
|
||||
@@ -17,43 +19,23 @@ def find_waste_vessel(G: nx.DiGraph) -> str:
|
||||
"""
|
||||
possible_waste_names = [
|
||||
"waste_workup",
|
||||
"flask_waste",
|
||||
"flask_waste",
|
||||
"bottle_waste",
|
||||
"waste",
|
||||
"waste_vessel",
|
||||
"waste_container"
|
||||
]
|
||||
|
||||
|
||||
for waste_name in possible_waste_names:
|
||||
if waste_name in G.nodes():
|
||||
return waste_name
|
||||
|
||||
|
||||
raise ValueError(f"未找到废液容器。尝试了以下名称: {possible_waste_names}")
|
||||
|
||||
|
||||
def find_connected_heatchill(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""
|
||||
查找与指定容器相连的加热冷却设备
|
||||
"""
|
||||
# 查找所有加热冷却设备节点
|
||||
heatchill_nodes = [node for node in G.nodes()
|
||||
if (G.nodes[node].get('class') or '') == 'virtual_heatchill']
|
||||
|
||||
# 检查哪个加热设备与目标容器相连(机械连接)
|
||||
for heatchill in heatchill_nodes:
|
||||
if G.has_edge(heatchill, vessel) or G.has_edge(vessel, heatchill):
|
||||
return heatchill
|
||||
|
||||
# 如果没有直接连接,返回第一个可用的加热设备
|
||||
if heatchill_nodes:
|
||||
return heatchill_nodes[0]
|
||||
|
||||
return None # 没有加热设备也可以工作,只是不能加热
|
||||
|
||||
|
||||
def generate_clean_vessel_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
solvent: str,
|
||||
volume: float,
|
||||
temp: float,
|
||||
@@ -61,7 +43,7 @@ def generate_clean_vessel_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成容器清洗操作的协议序列,复用 pump_protocol 的成熟算法
|
||||
|
||||
|
||||
清洗流程:
|
||||
1. 查找溶剂容器和废液容器
|
||||
2. 如果需要加热,启动加热设备
|
||||
@@ -70,63 +52,50 @@ def generate_clean_vessel_protocol(
|
||||
b. (可选) 等待清洗作用时间
|
||||
c. 使用 pump_protocol 将清洗液从目标容器转移到废液容器
|
||||
4. 如果加热了,停止加热
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为设备和容器,边为流体管道
|
||||
vessel: 要清洗的容器字典(包含id字段)
|
||||
solvent: 用于清洗的溶剂名称
|
||||
solvent: 用于清洗的溶剂名称
|
||||
volume: 每次清洗使用的溶剂体积
|
||||
temp: 清洗时的温度
|
||||
repeats: 清洗操作的重复次数,默认为 1
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 容器清洗操作的动作序列
|
||||
|
||||
Raises:
|
||||
ValueError: 当找不到必要的容器或设备时抛出异常
|
||||
|
||||
Examples:
|
||||
clean_protocol = generate_clean_vessel_protocol(G, {"id": "main_reactor"}, "water", 100.0, 60.0, 2)
|
||||
"""
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
action_sequence = []
|
||||
|
||||
print(f"CLEAN_VESSEL: 开始生成容器清洗协议")
|
||||
print(f" - 目标容器: {vessel} (ID: {vessel_id})")
|
||||
print(f" - 清洗溶剂: {solvent}")
|
||||
print(f" - 清洗体积: {volume} mL")
|
||||
print(f" - 清洗温度: {temp}°C")
|
||||
print(f" - 重复次数: {repeats}")
|
||||
|
||||
|
||||
debug_print(f"开始生成容器清洗协议: vessel={vessel_id}, solvent={solvent}, volume={volume}mL, temp={temp}°C, repeats={repeats}")
|
||||
|
||||
# 验证目标容器存在
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"目标容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
|
||||
# 查找溶剂容器
|
||||
try:
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
print(f"CLEAN_VESSEL: 找到溶剂容器: {solvent_vessel}")
|
||||
debug_print(f"找到溶剂容器: {solvent_vessel}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"无法找到溶剂容器: {str(e)}")
|
||||
|
||||
|
||||
# 查找废液容器
|
||||
try:
|
||||
waste_vessel = find_waste_vessel(G)
|
||||
print(f"CLEAN_VESSEL: 找到废液容器: {waste_vessel}")
|
||||
debug_print(f"找到废液容器: {waste_vessel}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"无法找到废液容器: {str(e)}")
|
||||
|
||||
|
||||
# 查找加热设备(可选)
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
if heatchill_id:
|
||||
print(f"CLEAN_VESSEL: 找到加热设备: {heatchill_id}")
|
||||
debug_print(f"找到加热设备: {heatchill_id}")
|
||||
else:
|
||||
print(f"CLEAN_VESSEL: 未找到加热设备,将在室温下清洗")
|
||||
|
||||
# 🔧 新增:记录清洗前的容器状态
|
||||
print(f"CLEAN_VESSEL: 记录清洗前容器状态...")
|
||||
debug_print(f"未找到加热设备,将在室温下清洗")
|
||||
|
||||
# 记录清洗前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -134,79 +103,69 @@ def generate_clean_vessel_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
print(f"CLEAN_VESSEL: 清洗前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 第一步:如果需要加热且有加热设备,启动加热
|
||||
if temp > 25.0 and heatchill_id:
|
||||
print(f"CLEAN_VESSEL: 启动加热至 {temp}°C")
|
||||
debug_print(f"启动加热至 {temp}°C")
|
||||
heatchill_start_action = {
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": temp,
|
||||
"purpose": f"cleaning with {solvent}"
|
||||
}
|
||||
}
|
||||
action_sequence.append(heatchill_start_action)
|
||||
|
||||
# 等待温度稳定
|
||||
|
||||
wait_action = {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 30} # 等待30秒让温度稳定
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 30}
|
||||
}
|
||||
action_sequence.append(wait_action)
|
||||
|
||||
|
||||
# 第二步:重复清洗操作
|
||||
for repeat in range(repeats):
|
||||
print(f"CLEAN_VESSEL: 执行第 {repeat + 1} 次清洗")
|
||||
|
||||
debug_print(f"执行第 {repeat + 1}/{repeats} 次清洗")
|
||||
|
||||
# 2a. 使用 pump_protocol 将溶剂转移到目标容器
|
||||
print(f"CLEAN_VESSEL: 将 {volume} mL {solvent} 转移到 {vessel_id}")
|
||||
try:
|
||||
# 调用成熟的 pump_protocol 算法
|
||||
add_solvent_actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel=solvent_vessel,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
to_vessel=vessel_id,
|
||||
volume=volume,
|
||||
flowrate=2.5, # 适中的流速,避免飞溅
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=2.5
|
||||
)
|
||||
action_sequence.extend(add_solvent_actions)
|
||||
|
||||
# 🔧 新增:更新容器体积(添加清洗溶剂)
|
||||
print(f"CLEAN_VESSEL: 更新容器体积 - 添加清洗溶剂 {volume:.2f}mL")
|
||||
|
||||
# 更新容器体积(添加清洗溶剂)
|
||||
if "data" not in vessel:
|
||||
vessel["data"] = {}
|
||||
|
||||
|
||||
if "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] += volume
|
||||
print(f"CLEAN_VESSEL: 添加溶剂后体积: {vessel['data']['liquid_volume'][0]:.2f}mL (+{volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [volume]
|
||||
print(f"CLEAN_VESSEL: 初始化清洗体积: {volume:.2f}mL")
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
vessel["data"]["liquid_volume"] += volume
|
||||
print(f"CLEAN_VESSEL: 添加溶剂后体积: {vessel['data']['liquid_volume']:.2f}mL (+{volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = volume
|
||||
print(f"CLEAN_VESSEL: 重置体积为: {volume:.2f}mL")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = volume
|
||||
print(f"CLEAN_VESSEL: 创建新体积记录: {volume:.2f}mL")
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] += volume
|
||||
@@ -214,58 +173,48 @@ def generate_clean_vessel_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = current_node_volume + volume
|
||||
|
||||
print(f"CLEAN_VESSEL: 图节点体积数据已更新")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"无法将溶剂转移到容器: {str(e)}")
|
||||
|
||||
# 2b. 等待清洗作用时间(让溶剂充分清洗容器)
|
||||
cleaning_wait_time = 60 if temp > 50.0 else 30 # 高温下等待更久
|
||||
print(f"CLEAN_VESSEL: 等待清洗作用 {cleaning_wait_time} 秒")
|
||||
|
||||
# 2b. 等待清洗作用时间
|
||||
cleaning_wait_time = 60 if temp > 50.0 else 30
|
||||
wait_action = {
|
||||
"action_name": "wait",
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": cleaning_wait_time}
|
||||
}
|
||||
action_sequence.append(wait_action)
|
||||
|
||||
|
||||
# 2c. 使用 pump_protocol 将清洗液转移到废液容器
|
||||
print(f"CLEAN_VESSEL: 将清洗液从 {vessel_id} 转移到废液容器")
|
||||
try:
|
||||
# 调用成熟的 pump_protocol 算法
|
||||
remove_waste_actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
from_vessel=vessel_id,
|
||||
to_vessel=waste_vessel,
|
||||
volume=volume,
|
||||
flowrate=2.5, # 适中的流速
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=2.5
|
||||
)
|
||||
action_sequence.extend(remove_waste_actions)
|
||||
|
||||
# 🔧 新增:更新容器体积(移除清洗液)
|
||||
print(f"CLEAN_VESSEL: 更新容器体积 - 移除清洗液 {volume:.2f}mL")
|
||||
|
||||
# 更新容器体积(移除清洗液)
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = max(0.0, vessel["data"]["liquid_volume"][0] - volume)
|
||||
print(f"CLEAN_VESSEL: 移除清洗液后体积: {vessel['data']['liquid_volume'][0]:.2f}mL (-{volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [0.0]
|
||||
print(f"CLEAN_VESSEL: 重置体积为0mL")
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
vessel["data"]["liquid_volume"] = max(0.0, current_volume - volume)
|
||||
print(f"CLEAN_VESSEL: 移除清洗液后体积: {vessel['data']['liquid_volume']:.2f}mL (-{volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = 0.0
|
||||
print(f"CLEAN_VESSEL: 重置体积为0mL")
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
vessel_node_data = G.nodes[vessel_id].get('data', {})
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = max(0.0, current_node_volume[0] - volume)
|
||||
@@ -273,34 +222,30 @@ def generate_clean_vessel_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [0.0]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = max(0.0, current_node_volume - volume)
|
||||
|
||||
print(f"CLEAN_VESSEL: 图节点体积数据已更新")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"无法将清洗液转移到废液容器: {str(e)}")
|
||||
|
||||
|
||||
# 2d. 清洗循环间的短暂等待
|
||||
if repeat < repeats - 1: # 不是最后一次清洗
|
||||
print(f"CLEAN_VESSEL: 清洗循环间等待")
|
||||
if repeat < repeats - 1:
|
||||
wait_action = {
|
||||
"action_name": "wait",
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10}
|
||||
}
|
||||
action_sequence.append(wait_action)
|
||||
|
||||
|
||||
# 第三步:如果加热了,停止加热
|
||||
if temp > 25.0 and heatchill_id:
|
||||
print(f"CLEAN_VESSEL: 停止加热")
|
||||
heatchill_stop_action = {
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
}
|
||||
}
|
||||
action_sequence.append(heatchill_stop_action)
|
||||
|
||||
# 🔧 新增:清洗完成后的状态报告
|
||||
|
||||
# 清洗完成后的状态
|
||||
final_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -308,20 +253,17 @@ def generate_clean_vessel_protocol(
|
||||
final_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
final_liquid_volume = current_volume
|
||||
|
||||
print(f"CLEAN_VESSEL: 清洗完成")
|
||||
print(f" - 清洗前体积: {original_liquid_volume:.2f}mL")
|
||||
print(f" - 清洗后体积: {final_liquid_volume:.2f}mL")
|
||||
print(f" - 生成了 {len(action_sequence)} 个动作")
|
||||
|
||||
|
||||
debug_print(f"清洗完成: {len(action_sequence)} 个动作, 体积 {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
# 便捷函数:常用清洗方案
|
||||
# 便捷函数
|
||||
def generate_quick_clean_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
solvent: str = "water",
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
solvent: str = "water",
|
||||
volume: float = 100.0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""快速清洗:室温,单次清洗"""
|
||||
@@ -329,9 +271,9 @@ def generate_quick_clean_protocol(
|
||||
|
||||
|
||||
def generate_thorough_clean_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
solvent: str = "water",
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
solvent: str = "water",
|
||||
volume: float = 150.0,
|
||||
temp: float = 60.0
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -340,13 +282,13 @@ def generate_thorough_clean_protocol(
|
||||
|
||||
|
||||
def generate_organic_clean_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
volume: float = 100.0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""有机清洗:先用有机溶剂,再用水清洗"""
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# 第一步:有机溶剂清洗
|
||||
try:
|
||||
organic_actions = generate_clean_vessel_protocol(
|
||||
@@ -354,96 +296,71 @@ def generate_organic_clean_protocol(
|
||||
)
|
||||
action_sequence.extend(organic_actions)
|
||||
except ValueError:
|
||||
# 如果没有丙酮,尝试乙醇
|
||||
try:
|
||||
organic_actions = generate_clean_vessel_protocol(
|
||||
G, vessel, "ethanol", volume, 25.0, 2
|
||||
)
|
||||
action_sequence.extend(organic_actions)
|
||||
except ValueError:
|
||||
print("警告:未找到有机溶剂,跳过有机清洗步骤")
|
||||
|
||||
debug_print("未找到有机溶剂,跳过有机清洗步骤")
|
||||
|
||||
# 第二步:水清洗
|
||||
water_actions = generate_clean_vessel_protocol(
|
||||
G, vessel, "water", volume, 25.0, 2
|
||||
)
|
||||
action_sequence.extend(water_actions)
|
||||
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
||||
"""获取容器中的液体体积(修复版)"""
|
||||
if vessel not in G.nodes():
|
||||
return 0.0
|
||||
|
||||
vessel_data = G.nodes[vessel].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
|
||||
total_volume = 0.0
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
# 支持两种格式:新格式 (name, volume) 和旧格式 (liquid_type, liquid_volume)
|
||||
volume = liquid.get('volume') or liquid.get('liquid_volume', 0.0)
|
||||
total_volume += volume
|
||||
|
||||
return total_volume
|
||||
|
||||
|
||||
def get_vessel_liquid_types(G: nx.DiGraph, vessel: str) -> List[str]:
|
||||
"""获取容器中所有液体的类型"""
|
||||
if vessel not in G.nodes():
|
||||
return []
|
||||
|
||||
|
||||
vessel_data = G.nodes[vessel].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
|
||||
|
||||
liquid_types = []
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
# 支持两种格式的液体类型字段
|
||||
liquid_type = liquid.get('liquid_type') or liquid.get('name', '')
|
||||
if liquid_type:
|
||||
liquid_types.append(liquid_type)
|
||||
|
||||
|
||||
return liquid_types
|
||||
|
||||
|
||||
def find_vessel_by_content(G: nx.DiGraph, content: str) -> List[str]:
|
||||
"""
|
||||
根据内容物查找所有匹配的容器
|
||||
返回匹配容器的ID列表
|
||||
"""
|
||||
matching_vessels = []
|
||||
|
||||
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
# 检查容器名称匹配
|
||||
node_name = G.nodes[node_id].get('name', '').lower()
|
||||
if content.lower() in node_id.lower() or content.lower() in node_name:
|
||||
matching_vessels.append(node_id)
|
||||
continue
|
||||
|
||||
# 检查液体类型匹配
|
||||
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
config_data = G.nodes[node_id].get('config', {})
|
||||
|
||||
# 检查 reagent_name 和 config.reagent
|
||||
|
||||
reagent_name = vessel_data.get('reagent_name', '').lower()
|
||||
config_reagent = config_data.get('reagent', '').lower()
|
||||
|
||||
if (content.lower() == reagent_name or
|
||||
|
||||
if (content.lower() == reagent_name or
|
||||
content.lower() == config_reagent):
|
||||
matching_vessels.append(node_id)
|
||||
continue
|
||||
|
||||
# 检查液体列表
|
||||
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
liquid_type = liquid.get('liquid_type') or liquid.get('name', '')
|
||||
if liquid_type.lower() == content.lower():
|
||||
matching_vessels.append(node_id)
|
||||
break
|
||||
|
||||
return matching_vessels
|
||||
|
||||
return matching_vessels
|
||||
|
||||
@@ -1,402 +1,19 @@
|
||||
from functools import partial
|
||||
|
||||
import networkx as nx
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Any, Union
|
||||
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import action_log
|
||||
from .utils.logger_util import debug_print, action_log
|
||||
from .utils.unit_parser import parse_volume_input, parse_mass_input, parse_time_input, parse_temperature_input
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel, find_connected_heatchill, find_connected_stirrer, find_solid_dispenser
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[DISSOLVE] {message}")
|
||||
|
||||
# 🆕 创建进度日志动作
|
||||
# 创建进度日志动作
|
||||
create_action_log = partial(action_log, prefix="[DISSOLVE]")
|
||||
|
||||
def parse_volume_input(volume_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析体积输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
volume_input: 体积输入(如 "10 mL", "?", 10.0)
|
||||
|
||||
Returns:
|
||||
float: 体积(毫升)
|
||||
"""
|
||||
if isinstance(volume_input, (int, float)):
|
||||
debug_print(f"📏 体积输入为数值: {volume_input}")
|
||||
return float(volume_input)
|
||||
|
||||
if not volume_input or not str(volume_input).strip():
|
||||
debug_print(f"⚠️ 体积输入为空,返回0.0mL")
|
||||
return 0.0
|
||||
|
||||
volume_str = str(volume_input).lower().strip()
|
||||
debug_print(f"🔍 解析体积输入: '{volume_str}'")
|
||||
|
||||
# 处理未知体积
|
||||
if volume_str in ['?', 'unknown', 'tbd', 'to be determined']:
|
||||
default_volume = 50.0 # 默认50mL
|
||||
debug_print(f"❓ 检测到未知体积,使用默认值: {default_volume}mL 🎯")
|
||||
return default_volume
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
volume_clean = re.sub(r'\s+', '', volume_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(ml|l|μl|ul|microliter|milliliter|liter)?', volume_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"❌ 无法解析体积: '{volume_str}',使用默认值50mL")
|
||||
return 50.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 'ml' # 默认单位为毫升
|
||||
|
||||
# 转换为毫升
|
||||
if unit in ['l', 'liter']:
|
||||
volume = value * 1000.0 # L -> mL
|
||||
debug_print(f"🔄 体积转换: {value}L → {volume}mL")
|
||||
elif unit in ['μl', 'ul', 'microliter']:
|
||||
volume = value / 1000.0 # μL -> mL
|
||||
debug_print(f"🔄 体积转换: {value}μL → {volume}mL")
|
||||
else: # ml, milliliter 或默认
|
||||
volume = value # 已经是mL
|
||||
debug_print(f"✅ 体积已为mL: {volume}mL")
|
||||
|
||||
return volume
|
||||
|
||||
def parse_mass_input(mass_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析质量输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
mass_input: 质量输入(如 "2.9 g", "?", 2.5)
|
||||
|
||||
Returns:
|
||||
float: 质量(克)
|
||||
"""
|
||||
if isinstance(mass_input, (int, float)):
|
||||
debug_print(f"⚖️ 质量输入为数值: {mass_input}g")
|
||||
return float(mass_input)
|
||||
|
||||
if not mass_input or not str(mass_input).strip():
|
||||
debug_print(f"⚠️ 质量输入为空,返回0.0g")
|
||||
return 0.0
|
||||
|
||||
mass_str = str(mass_input).lower().strip()
|
||||
debug_print(f"🔍 解析质量输入: '{mass_str}'")
|
||||
|
||||
# 处理未知质量
|
||||
if mass_str in ['?', 'unknown', 'tbd', 'to be determined']:
|
||||
default_mass = 1.0 # 默认1g
|
||||
debug_print(f"❓ 检测到未知质量,使用默认值: {default_mass}g 🎯")
|
||||
return default_mass
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
mass_clean = re.sub(r'\s+', '', mass_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(g|mg|kg|gram|milligram|kilogram)?', mass_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"❌ 无法解析质量: '{mass_str}',返回0.0g")
|
||||
return 0.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 'g' # 默认单位为克
|
||||
|
||||
# 转换为克
|
||||
if unit in ['mg', 'milligram']:
|
||||
mass = value / 1000.0 # mg -> g
|
||||
debug_print(f"🔄 质量转换: {value}mg → {mass}g")
|
||||
elif unit in ['kg', 'kilogram']:
|
||||
mass = value * 1000.0 # kg -> g
|
||||
debug_print(f"🔄 质量转换: {value}kg → {mass}g")
|
||||
else: # g, gram 或默认
|
||||
mass = value # 已经是g
|
||||
debug_print(f"✅ 质量已为g: {mass}g")
|
||||
|
||||
return mass
|
||||
|
||||
def parse_time_input(time_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析时间输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
time_input: 时间输入(如 "30 min", "1 h", "?", 60.0)
|
||||
|
||||
Returns:
|
||||
float: 时间(秒)
|
||||
"""
|
||||
if isinstance(time_input, (int, float)):
|
||||
debug_print(f"⏱️ 时间输入为数值: {time_input}秒")
|
||||
return float(time_input)
|
||||
|
||||
if not time_input or not str(time_input).strip():
|
||||
debug_print(f"⚠️ 时间输入为空,返回0秒")
|
||||
return 0.0
|
||||
|
||||
time_str = str(time_input).lower().strip()
|
||||
debug_print(f"🔍 解析时间输入: '{time_str}'")
|
||||
|
||||
# 处理未知时间
|
||||
if time_str in ['?', 'unknown', 'tbd']:
|
||||
default_time = 600.0 # 默认10分钟
|
||||
debug_print(f"❓ 检测到未知时间,使用默认值: {default_time}s (10分钟) ⏰")
|
||||
return default_time
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
time_clean = re.sub(r'\s+', '', time_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(s|sec|second|min|minute|h|hr|hour|d|day)?', time_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"❌ 无法解析时间: '{time_str}',返回0s")
|
||||
return 0.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 's' # 默认单位为秒
|
||||
|
||||
# 转换为秒
|
||||
if unit in ['min', 'minute']:
|
||||
time_sec = value * 60.0 # min -> s
|
||||
debug_print(f"🔄 时间转换: {value}分钟 → {time_sec}秒")
|
||||
elif unit in ['h', 'hr', 'hour']:
|
||||
time_sec = value * 3600.0 # h -> s
|
||||
debug_print(f"🔄 时间转换: {value}小时 → {time_sec}秒")
|
||||
elif unit in ['d', 'day']:
|
||||
time_sec = value * 86400.0 # d -> s
|
||||
debug_print(f"🔄 时间转换: {value}天 → {time_sec}秒")
|
||||
else: # s, sec, second 或默认
|
||||
time_sec = value # 已经是s
|
||||
debug_print(f"✅ 时间已为秒: {time_sec}秒")
|
||||
|
||||
return time_sec
|
||||
|
||||
def parse_temperature_input(temp_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析温度输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
temp_input: 温度输入(如 "60 °C", "room temperature", "?", 25.0)
|
||||
|
||||
Returns:
|
||||
float: 温度(摄氏度)
|
||||
"""
|
||||
if isinstance(temp_input, (int, float)):
|
||||
debug_print(f"🌡️ 温度输入为数值: {temp_input}°C")
|
||||
return float(temp_input)
|
||||
|
||||
if not temp_input or not str(temp_input).strip():
|
||||
debug_print(f"⚠️ 温度输入为空,使用默认室温25°C")
|
||||
return 25.0 # 默认室温
|
||||
|
||||
temp_str = str(temp_input).lower().strip()
|
||||
debug_print(f"🔍 解析温度输入: '{temp_str}'")
|
||||
|
||||
# 处理特殊温度描述
|
||||
temp_aliases = {
|
||||
'room temperature': 25.0,
|
||||
'rt': 25.0,
|
||||
'ambient': 25.0,
|
||||
'cold': 4.0,
|
||||
'ice': 0.0,
|
||||
'reflux': 80.0, # 默认回流温度
|
||||
'?': 25.0,
|
||||
'unknown': 25.0
|
||||
}
|
||||
|
||||
if temp_str in temp_aliases:
|
||||
result = temp_aliases[temp_str]
|
||||
debug_print(f"🏷️ 温度别名解析: '{temp_str}' → {result}°C")
|
||||
return result
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
temp_clean = re.sub(r'\s+', '', temp_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(°c|c|celsius|°f|f|fahrenheit|k|kelvin)?', temp_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"❌ 无法解析温度: '{temp_str}',使用默认值25°C")
|
||||
return 25.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 'c' # 默认单位为摄氏度
|
||||
|
||||
# 转换为摄氏度
|
||||
if unit in ['°f', 'f', 'fahrenheit']:
|
||||
temp_c = (value - 32) * 5/9 # F -> C
|
||||
debug_print(f"🔄 温度转换: {value}°F → {temp_c:.1f}°C")
|
||||
elif unit in ['k', 'kelvin']:
|
||||
temp_c = value - 273.15 # K -> C
|
||||
debug_print(f"🔄 温度转换: {value}K → {temp_c:.1f}°C")
|
||||
else: # °c, c, celsius 或默认
|
||||
temp_c = value # 已经是C
|
||||
debug_print(f"✅ 温度已为°C: {temp_c}°C")
|
||||
|
||||
return temp_c
|
||||
|
||||
def find_solvent_vessel(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""增强版溶剂容器查找,支持多种匹配模式"""
|
||||
debug_print(f"🔍 开始查找溶剂 '{solvent}' 的容器...")
|
||||
|
||||
# 🔧 方法1:直接搜索 data.reagent_name 和 config.reagent
|
||||
debug_print(f"📋 方法1: 搜索reagent字段...")
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node].get('data', {})
|
||||
node_type = G.nodes[node].get('type', '')
|
||||
config_data = G.nodes[node].get('config', {})
|
||||
|
||||
# 只搜索容器类型的节点
|
||||
if node_type == 'container':
|
||||
reagent_name = node_data.get('reagent_name', '').lower()
|
||||
config_reagent = config_data.get('reagent', '').lower()
|
||||
|
||||
# 精确匹配
|
||||
if reagent_name == solvent.lower() or config_reagent == solvent.lower():
|
||||
debug_print(f"✅ 通过reagent字段精确匹配到容器: {node} 🎯")
|
||||
return node
|
||||
|
||||
# 模糊匹配
|
||||
if (solvent.lower() in reagent_name and reagent_name) or \
|
||||
(solvent.lower() in config_reagent and config_reagent):
|
||||
debug_print(f"✅ 通过reagent字段模糊匹配到容器: {node} 🔍")
|
||||
return node
|
||||
|
||||
# 🔧 方法2:常见的容器命名规则
|
||||
debug_print(f"📋 方法2: 使用命名规则查找...")
|
||||
solvent_clean = solvent.lower().replace(' ', '_').replace('-', '_')
|
||||
possible_names = [
|
||||
solvent_clean,
|
||||
f"flask_{solvent_clean}",
|
||||
f"bottle_{solvent_clean}",
|
||||
f"vessel_{solvent_clean}",
|
||||
f"{solvent_clean}_flask",
|
||||
f"{solvent_clean}_bottle",
|
||||
f"solvent_{solvent_clean}",
|
||||
f"reagent_{solvent_clean}",
|
||||
f"reagent_bottle_{solvent_clean}",
|
||||
f"reagent_bottle_1", # 通用试剂瓶
|
||||
f"reagent_bottle_2",
|
||||
f"reagent_bottle_3"
|
||||
]
|
||||
|
||||
debug_print(f"🔍 尝试的容器名称: {possible_names[:5]}... (共{len(possible_names)}个)")
|
||||
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
node_type = G.nodes[name].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"✅ 通过命名规则找到容器: {name} 📝")
|
||||
return name
|
||||
|
||||
# 🔧 方法3:节点名称模糊匹配
|
||||
debug_print(f"📋 方法3: 节点名称模糊匹配...")
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
if node_data.get('type') == 'container':
|
||||
# 检查节点名称是否包含溶剂名称
|
||||
if solvent_clean in node_id.lower():
|
||||
debug_print(f"✅ 通过节点名称模糊匹配到容器: {node_id} 🔍")
|
||||
return node_id
|
||||
|
||||
# 检查液体类型匹配
|
||||
vessel_data = node_data.get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
liquid_type = liquid.get('liquid_type') or liquid.get('name', '')
|
||||
if liquid_type.lower() == solvent.lower():
|
||||
debug_print(f"✅ 通过液体类型匹配到容器: {node_id} 💧")
|
||||
return node_id
|
||||
|
||||
# 🔧 方法4:使用第一个试剂瓶作为备选
|
||||
debug_print(f"📋 方法4: 查找备选试剂瓶...")
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
if (node_data.get('type') == 'container' and
|
||||
('reagent' in node_id.lower() or 'bottle' in node_id.lower() or 'flask' in node_id.lower())):
|
||||
debug_print(f"⚠️ 未找到专用容器,使用备选试剂瓶: {node_id} 🔄")
|
||||
return node_id
|
||||
|
||||
debug_print(f"❌ 所有方法都失败了,无法找到容器!")
|
||||
raise ValueError(f"找不到溶剂 '{solvent}' 对应的容器")
|
||||
|
||||
def find_connected_heatchill(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找连接到指定容器的加热搅拌器"""
|
||||
debug_print(f"🔍 查找连接到容器 '{vessel}' 的加热搅拌器...")
|
||||
|
||||
heatchill_nodes = []
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '').lower()
|
||||
if 'heatchill' in node_class:
|
||||
heatchill_nodes.append(node)
|
||||
debug_print(f"📋 发现加热搅拌器: {node}")
|
||||
|
||||
debug_print(f"📊 共找到 {len(heatchill_nodes)} 个加热搅拌器")
|
||||
|
||||
# 查找连接到容器的加热器
|
||||
for heatchill in heatchill_nodes:
|
||||
if G.has_edge(heatchill, vessel) or G.has_edge(vessel, heatchill):
|
||||
debug_print(f"✅ 找到连接的加热搅拌器: {heatchill} 🔗")
|
||||
return heatchill
|
||||
|
||||
# 返回第一个加热器
|
||||
if heatchill_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的加热搅拌器,使用第一个: {heatchill_nodes[0]} 🔄")
|
||||
return heatchill_nodes[0]
|
||||
|
||||
debug_print(f"❌ 未找到任何加热搅拌器")
|
||||
return ""
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找连接到指定容器的搅拌器"""
|
||||
debug_print(f"🔍 查找连接到容器 '{vessel}' 的搅拌器...")
|
||||
|
||||
stirrer_nodes = []
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '').lower()
|
||||
if 'stirrer' in node_class:
|
||||
stirrer_nodes.append(node)
|
||||
debug_print(f"📋 发现搅拌器: {node}")
|
||||
|
||||
debug_print(f"📊 共找到 {len(stirrer_nodes)} 个搅拌器")
|
||||
|
||||
# 查找连接到容器的搅拌器
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 找到连接的搅拌器: {stirrer} 🔗")
|
||||
return stirrer
|
||||
|
||||
# 返回第一个搅拌器
|
||||
if stirrer_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的搅拌器,使用第一个: {stirrer_nodes[0]} 🔄")
|
||||
return stirrer_nodes[0]
|
||||
|
||||
debug_print(f"❌ 未找到任何搅拌器")
|
||||
return ""
|
||||
|
||||
def find_solid_dispenser(G: nx.DiGraph) -> str:
|
||||
"""查找固体加样器"""
|
||||
debug_print(f"🔍 查找固体加样器...")
|
||||
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '').lower()
|
||||
if 'solid_dispenser' in node_class or 'dispenser' in node_class:
|
||||
debug_print(f"✅ 找到固体加样器: {node} 🥄")
|
||||
return node
|
||||
|
||||
debug_print(f"❌ 未找到固体加样器")
|
||||
return ""
|
||||
|
||||
def generate_dissolve_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
@@ -436,43 +53,21 @@ def generate_dissolve_protocol(
|
||||
- mol: "0.12 mol", "16.2 mmol"
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
# 从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print("🧪 开始生成溶解协议")
|
||||
debug_print(f"📋 原始参数:")
|
||||
debug_print(f" 🥼 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 💧 solvent: '{solvent}'")
|
||||
debug_print(f" 📏 volume: {volume} (类型: {type(volume)})")
|
||||
debug_print(f" ⚖️ mass: {mass} (类型: {type(mass)})")
|
||||
debug_print(f" 🌡️ temp: {temp} (类型: {type(temp)})")
|
||||
debug_print(f" ⏱️ time: {time} (类型: {type(time)})")
|
||||
debug_print(f" 🧪 reagent: '{reagent}'")
|
||||
debug_print(f" 🧬 mol: '{mol}'")
|
||||
debug_print(f" 🎯 event: '{event}'")
|
||||
debug_print(f" 📦 kwargs: {kwargs}") # 显示额外参数
|
||||
debug_print("=" * 60)
|
||||
|
||||
|
||||
debug_print(f"溶解协议: vessel={vessel_id}, solvent='{solvent}', volume={volume}, "
|
||||
f"mass={mass}, temp={temp}, time={time}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# === 参数验证 ===
|
||||
debug_print("🔍 步骤1: 参数验证...")
|
||||
action_sequence.append(create_action_log(f"开始溶解操作 - 容器: {vessel_id}", "🎬"))
|
||||
|
||||
|
||||
if not vessel_id:
|
||||
debug_print("❌ vessel 参数不能为空")
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
debug_print(f"❌ 容器 '{vessel_id}' 不存在于系统中")
|
||||
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print("✅ 基本参数验证通过")
|
||||
action_sequence.append(create_action_log("参数验证通过", "✅"))
|
||||
|
||||
# 🔧 新增:记录溶解前的容器状态
|
||||
debug_print("🔍 记录溶解前容器状态...")
|
||||
|
||||
# 记录溶解前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -480,30 +75,16 @@ def generate_dissolve_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
debug_print(f"📊 溶解前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
# === 🔧 关键修复:参数解析 ===
|
||||
debug_print("🔍 步骤2: 参数解析...")
|
||||
action_sequence.append(create_action_log("正在解析溶解参数...", "🔍"))
|
||||
|
||||
# 解析各种参数为数值
|
||||
|
||||
# === 参数解析 ===
|
||||
final_volume = parse_volume_input(volume)
|
||||
final_mass = parse_mass_input(mass)
|
||||
final_temp = parse_temperature_input(temp)
|
||||
final_time = parse_time_input(time)
|
||||
|
||||
debug_print(f"📊 解析结果:")
|
||||
debug_print(f" 📏 体积: {final_volume}mL")
|
||||
debug_print(f" ⚖️ 质量: {final_mass}g")
|
||||
debug_print(f" 🌡️ 温度: {final_temp}°C")
|
||||
debug_print(f" ⏱️ 时间: {final_time}s")
|
||||
debug_print(f" 🧪 试剂: '{reagent}'")
|
||||
debug_print(f" 🧬 摩尔: '{mol}'")
|
||||
debug_print(f" 🎯 事件: '{event}'")
|
||||
|
||||
|
||||
debug_print(f"参数解析: vol={final_volume}mL, mass={final_mass}g, temp={final_temp}°C, time={final_time}s")
|
||||
|
||||
# === 判断溶解类型 ===
|
||||
debug_print("🔍 步骤3: 判断溶解类型...")
|
||||
action_sequence.append(create_action_log("正在判断溶解类型...", "🔍"))
|
||||
|
||||
# 判断是固体溶解还是液体溶解
|
||||
is_solid_dissolve = (final_mass > 0 or (mol and mol.strip() != "") or (reagent and reagent.strip() != ""))
|
||||
@@ -515,49 +96,31 @@ def generate_dissolve_protocol(
|
||||
final_volume = 50.0
|
||||
if not solvent:
|
||||
solvent = "water" # 默认溶剂
|
||||
debug_print("⚠️ 未明确指定溶解参数,默认为50mL水溶解")
|
||||
debug_print("未明确指定溶解参数,默认为50mL水溶解")
|
||||
|
||||
dissolve_type = "固体溶解" if is_solid_dissolve else "液体溶解"
|
||||
dissolve_emoji = "🧂" if is_solid_dissolve else "💧"
|
||||
debug_print(f"📋 溶解类型: {dissolve_type} {dissolve_emoji}")
|
||||
|
||||
action_sequence.append(create_action_log(f"确定溶解类型: {dissolve_type} {dissolve_emoji}", "📋"))
|
||||
|
||||
debug_print(f"溶解类型: {dissolve_type}")
|
||||
|
||||
action_sequence.append(create_action_log(f"溶解类型: {dissolve_type}", "📋"))
|
||||
|
||||
# === 查找设备 ===
|
||||
debug_print("🔍 步骤4: 查找设备...")
|
||||
action_sequence.append(create_action_log("正在查找相关设备...", "🔍"))
|
||||
|
||||
# 查找加热搅拌器
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
|
||||
# 优先使用加热搅拌器,否则使用独立搅拌器
|
||||
stir_device_id = heatchill_id or stirrer_id
|
||||
|
||||
debug_print(f"📊 设备映射:")
|
||||
debug_print(f" 🔥 加热器: '{heatchill_id}'")
|
||||
debug_print(f" 🌪️ 搅拌器: '{stirrer_id}'")
|
||||
debug_print(f" 🎯 使用设备: '{stir_device_id}'")
|
||||
|
||||
if heatchill_id:
|
||||
action_sequence.append(create_action_log(f"找到加热搅拌器: {heatchill_id}", "🔥"))
|
||||
elif stirrer_id:
|
||||
action_sequence.append(create_action_log(f"找到搅拌器: {stirrer_id}", "🌪️"))
|
||||
else:
|
||||
debug_print(f"设备: heatchill='{heatchill_id}', stirrer='{stirrer_id}'")
|
||||
|
||||
if not stir_device_id:
|
||||
action_sequence.append(create_action_log("未找到搅拌设备,将跳过搅拌", "⚠️"))
|
||||
|
||||
# === 执行溶解流程 ===
|
||||
debug_print("🔍 步骤5: 执行溶解流程...")
|
||||
|
||||
try:
|
||||
# 步骤5.1: 启动加热搅拌(如果需要)
|
||||
# 启动加热搅拌(如果需要)
|
||||
if stir_device_id and (final_temp > 25.0 or final_time > 0 or stir_speed > 0):
|
||||
debug_print(f"🔍 5.1: 启动加热搅拌,温度: {final_temp}°C")
|
||||
action_sequence.append(create_action_log(f"准备加热搅拌 (目标温度: {final_temp}°C)", "🔥"))
|
||||
|
||||
|
||||
if heatchill_id and (final_temp > 25.0 or final_time > 0):
|
||||
# 使用加热搅拌器
|
||||
action_sequence.append(create_action_log(f"启动加热搅拌器 {heatchill_id}", "🔥"))
|
||||
|
||||
heatchill_action = {
|
||||
"device_id": heatchill_id,
|
||||
@@ -573,7 +136,6 @@ def generate_dissolve_protocol(
|
||||
# 等待温度稳定
|
||||
if final_temp > 25.0:
|
||||
wait_time = min(60, abs(final_temp - 25.0) * 1.5)
|
||||
action_sequence.append(create_action_log(f"等待温度稳定 ({wait_time:.0f}秒)", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": wait_time}
|
||||
@@ -581,7 +143,6 @@ def generate_dissolve_protocol(
|
||||
|
||||
elif stirrer_id:
|
||||
# 使用独立搅拌器
|
||||
action_sequence.append(create_action_log(f"启动搅拌器 {stirrer_id} (速度: {stir_speed}rpm)", "🌪️"))
|
||||
|
||||
stir_action = {
|
||||
"device_id": stirrer_id,
|
||||
@@ -593,9 +154,8 @@ def generate_dissolve_protocol(
|
||||
}
|
||||
}
|
||||
action_sequence.append(stir_action)
|
||||
|
||||
|
||||
# 等待搅拌稳定
|
||||
action_sequence.append(create_action_log("等待搅拌稳定...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5}
|
||||
@@ -603,12 +163,8 @@ def generate_dissolve_protocol(
|
||||
|
||||
if is_solid_dissolve:
|
||||
# === 固体溶解路径 ===
|
||||
debug_print(f"🔍 5.2: 使用固体溶解路径")
|
||||
action_sequence.append(create_action_log("开始固体溶解流程", "🧂"))
|
||||
|
||||
solid_dispenser = find_solid_dispenser(G)
|
||||
if solid_dispenser:
|
||||
action_sequence.append(create_action_log(f"找到固体加样器: {solid_dispenser}", "🥄"))
|
||||
|
||||
# 固体加样
|
||||
add_kwargs = {
|
||||
@@ -620,42 +176,27 @@ def generate_dissolve_protocol(
|
||||
|
||||
if final_mass > 0:
|
||||
add_kwargs["mass"] = str(final_mass)
|
||||
action_sequence.append(create_action_log(f"准备添加固体: {final_mass}g", "⚖️"))
|
||||
if mol and mol.strip():
|
||||
add_kwargs["mol"] = mol
|
||||
action_sequence.append(create_action_log(f"按摩尔数添加: {mol}", "🧬"))
|
||||
|
||||
action_sequence.append(create_action_log("开始固体加样操作", "🥄"))
|
||||
|
||||
action_sequence.append({
|
||||
"device_id": solid_dispenser,
|
||||
"action_name": "add_solid",
|
||||
"action_kwargs": add_kwargs
|
||||
})
|
||||
|
||||
debug_print(f"✅ 固体加样完成")
|
||||
action_sequence.append(create_action_log("固体加样完成", "✅"))
|
||||
|
||||
# 🔧 新增:固体溶解体积运算 - 固体本身不会显著增加体积,但可能有少量变化
|
||||
debug_print(f"🔧 固体溶解 - 体积变化很小,主要是质量变化")
|
||||
# 固体通常不会显著改变液体体积,这里只记录日志
|
||||
action_sequence.append(create_action_log(f"固体已添加: {final_mass}g", "📊"))
|
||||
|
||||
# 固体溶解体积运算 - 固体本身不会显著增加体积
|
||||
|
||||
else:
|
||||
debug_print("⚠️ 未找到固体加样器,跳过固体添加")
|
||||
debug_print("未找到固体加样器,跳过固体添加")
|
||||
action_sequence.append(create_action_log("未找到固体加样器,无法添加固体", "❌"))
|
||||
|
||||
elif is_liquid_dissolve:
|
||||
# === 液体溶解路径 ===
|
||||
debug_print(f"🔍 5.3: 使用液体溶解路径")
|
||||
action_sequence.append(create_action_log("开始液体溶解流程", "💧"))
|
||||
|
||||
# 查找溶剂容器
|
||||
action_sequence.append(create_action_log("正在查找溶剂容器...", "🔍"))
|
||||
try:
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
action_sequence.append(create_action_log(f"找到溶剂容器: {solvent_vessel}", "🧪"))
|
||||
except ValueError as e:
|
||||
debug_print(f"⚠️ {str(e)},跳过溶剂添加")
|
||||
debug_print(f"溶剂容器查找失败: {str(e)},跳过溶剂添加")
|
||||
action_sequence.append(create_action_log(f"溶剂容器查找失败: {str(e)}", "❌"))
|
||||
solvent_vessel = None
|
||||
|
||||
@@ -663,10 +204,7 @@ def generate_dissolve_protocol(
|
||||
# 计算流速 - 溶解时通常用较慢的速度,避免飞溅
|
||||
flowrate = 1.0 # 较慢的注入速度
|
||||
transfer_flowrate = 0.5 # 较慢的转移速度
|
||||
|
||||
action_sequence.append(create_action_log(f"设置流速: {flowrate}mL/min (缓慢注入)", "⚡"))
|
||||
action_sequence.append(create_action_log(f"开始转移 {final_volume}mL {solvent}", "🚰"))
|
||||
|
||||
|
||||
# 调用pump protocol
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
@@ -688,12 +226,9 @@ def generate_dissolve_protocol(
|
||||
**kwargs
|
||||
)
|
||||
action_sequence.extend(pump_actions)
|
||||
debug_print(f"✅ 溶剂转移完成,添加了 {len(pump_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"溶剂转移完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
|
||||
# 🔧 新增:液体溶解体积运算 - 添加溶剂后更新容器体积
|
||||
debug_print(f"🔧 更新容器液体体积 - 添加溶剂 {final_volume:.2f}mL")
|
||||
|
||||
|
||||
# 液体溶解体积运算 - 添加溶剂后更新容器体积
|
||||
|
||||
# 确保vessel有data字段
|
||||
if "data" not in vessel:
|
||||
vessel["data"] = {}
|
||||
@@ -703,19 +238,14 @@ def generate_dissolve_protocol(
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] += final_volume
|
||||
debug_print(f"📊 添加溶剂后体积: {vessel['data']['liquid_volume'][0]:.2f}mL (+{final_volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [final_volume]
|
||||
debug_print(f"📊 初始化溶解体积: {final_volume:.2f}mL")
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
vessel["data"]["liquid_volume"] += final_volume
|
||||
debug_print(f"📊 添加溶剂后体积: {vessel['data']['liquid_volume']:.2f}mL (+{final_volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = final_volume
|
||||
debug_print(f"📊 重置体积为: {final_volume:.2f}mL")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = final_volume
|
||||
debug_print(f"📊 创建新体积记录: {final_volume:.2f}mL")
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
@@ -732,27 +262,19 @@ def generate_dissolve_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [final_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = current_node_volume + final_volume
|
||||
|
||||
debug_print(f"✅ 图节点体积数据已更新")
|
||||
|
||||
action_sequence.append(create_action_log(f"容器体积已更新 (+{final_volume:.2f}mL)", "📊"))
|
||||
|
||||
|
||||
# 溶剂添加后等待
|
||||
action_sequence.append(create_action_log("溶剂添加后短暂等待...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5}
|
||||
})
|
||||
|
||||
# 步骤5.4: 等待溶解完成
|
||||
# 等待溶解完成
|
||||
if final_time > 0:
|
||||
debug_print(f"🔍 5.4: 等待溶解完成 - {final_time}s")
|
||||
wait_minutes = final_time / 60
|
||||
action_sequence.append(create_action_log(f"开始溶解等待 ({wait_minutes:.1f}分钟)", "⏰"))
|
||||
|
||||
|
||||
if heatchill_id:
|
||||
# 使用定时加热搅拌
|
||||
action_sequence.append(create_action_log(f"使用加热搅拌器进行定时溶解", "🔥"))
|
||||
|
||||
dissolve_action = {
|
||||
"device_id": heatchill_id,
|
||||
@@ -770,7 +292,6 @@ def generate_dissolve_protocol(
|
||||
|
||||
elif stirrer_id:
|
||||
# 使用定时搅拌
|
||||
action_sequence.append(create_action_log(f"使用搅拌器进行定时溶解", "🌪️"))
|
||||
|
||||
stir_action = {
|
||||
"device_id": stirrer_id,
|
||||
@@ -787,7 +308,6 @@ def generate_dissolve_protocol(
|
||||
|
||||
else:
|
||||
# 简单等待
|
||||
action_sequence.append(create_action_log(f"简单等待溶解完成", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": final_time}
|
||||
@@ -795,9 +315,7 @@ def generate_dissolve_protocol(
|
||||
|
||||
# 步骤5.5: 停止加热搅拌(如果需要)
|
||||
if heatchill_id and final_time == 0 and final_temp > 25.0:
|
||||
debug_print(f"🔍 5.5: 停止加热器")
|
||||
action_sequence.append(create_action_log("停止加热搅拌器", "🛑"))
|
||||
|
||||
|
||||
stop_action = {
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
@@ -808,7 +326,7 @@ def generate_dissolve_protocol(
|
||||
action_sequence.append(stop_action)
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 溶解流程执行失败: {str(e)}")
|
||||
debug_print(f"溶解流程执行失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"溶解流程失败: {str(e)}", "❌"))
|
||||
# 添加错误日志
|
||||
action_sequence.append({
|
||||
@@ -829,23 +347,8 @@ def generate_dissolve_protocol(
|
||||
final_liquid_volume = current_volume
|
||||
|
||||
# === 最终结果 ===
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 溶解协议生成完成")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)}")
|
||||
debug_print(f" 🥼 容器: {vessel_id}")
|
||||
debug_print(f" {dissolve_emoji} 溶解类型: {dissolve_type}")
|
||||
if is_liquid_dissolve:
|
||||
debug_print(f" 💧 溶剂: {solvent} ({final_volume}mL)")
|
||||
if is_solid_dissolve:
|
||||
debug_print(f" 🧪 试剂: {reagent}")
|
||||
debug_print(f" ⚖️ 质量: {final_mass}g")
|
||||
debug_print(f" 🧬 摩尔: {mol}")
|
||||
debug_print(f" 🌡️ 温度: {final_temp}°C")
|
||||
debug_print(f" ⏱️ 时间: {final_time}s")
|
||||
debug_print(f" 📊 溶解前体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" 📊 溶解后体积: {final_liquid_volume:.2f}mL")
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"溶解协议完成: {vessel_id}, 类型={dissolve_type}, "
|
||||
f"动作数={len(action_sequence)}, 体积={original_liquid_volume:.2f}→{final_liquid_volume:.2f}mL")
|
||||
|
||||
# 添加完成日志
|
||||
summary_msg = f"溶解协议完成: {vessel_id}"
|
||||
@@ -854,7 +357,7 @@ def generate_dissolve_protocol(
|
||||
if is_solid_dissolve:
|
||||
summary_msg += f" (溶解 {final_mass}g {reagent})"
|
||||
|
||||
action_sequence.append(create_action_log(summary_msg, "🎉"))
|
||||
action_sequence.append(create_action_log(summary_msg, "✅"))
|
||||
|
||||
return action_sequence
|
||||
|
||||
@@ -866,7 +369,7 @@ def dissolve_solid_by_mass(G: nx.DiGraph, vessel: dict, reagent: str, mass: Unio
|
||||
temp: Union[str, float] = 25.0, time: Union[str, float] = "10 min") -> List[Dict[str, Any]]:
|
||||
"""按质量溶解固体"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🧂 快速固体溶解: {reagent} ({mass}) → {vessel_id}")
|
||||
debug_print(f"快速固体溶解: {reagent} ({mass}) → {vessel_id}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
mass=mass,
|
||||
@@ -879,7 +382,7 @@ def dissolve_solid_by_moles(G: nx.DiGraph, vessel: dict, reagent: str, mol: str,
|
||||
temp: Union[str, float] = 25.0, time: Union[str, float] = "10 min") -> List[Dict[str, Any]]:
|
||||
"""按摩尔数溶解固体"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🧬 按摩尔数溶解固体: {reagent} ({mol}) → {vessel_id}")
|
||||
debug_print(f"按摩尔数溶解固体: {reagent} ({mol}) → {vessel_id}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
mol=mol,
|
||||
@@ -892,7 +395,7 @@ def dissolve_with_solvent(G: nx.DiGraph, vessel: dict, solvent: str, volume: Uni
|
||||
temp: Union[str, float] = 25.0, time: Union[str, float] = "5 min") -> List[Dict[str, Any]]:
|
||||
"""用溶剂溶解"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"💧 溶剂溶解: {solvent} ({volume}) → {vessel_id}")
|
||||
debug_print(f"溶剂溶解: {solvent} ({volume}) → {vessel_id}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
solvent=solvent,
|
||||
@@ -904,7 +407,7 @@ def dissolve_with_solvent(G: nx.DiGraph, vessel: dict, solvent: str, volume: Uni
|
||||
def dissolve_at_room_temp(G: nx.DiGraph, vessel: dict, solvent: str, volume: Union[str, float]) -> List[Dict[str, Any]]:
|
||||
"""室温溶解"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🌡️ 室温溶解: {solvent} ({volume}) → {vessel_id}")
|
||||
debug_print(f"室温溶解: {solvent} ({volume}) → {vessel_id}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
solvent=solvent,
|
||||
@@ -917,7 +420,7 @@ def dissolve_with_heating(G: nx.DiGraph, vessel: dict, solvent: str, volume: Uni
|
||||
temp: Union[str, float] = "60 °C", time: Union[str, float] = "15 min") -> List[Dict[str, Any]]:
|
||||
"""加热溶解"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🔥 加热溶解: {solvent} ({volume}) → {vessel_id} @ {temp}")
|
||||
debug_print(f"加热溶解: {solvent} ({volume}) → {vessel_id} @ {temp}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
solvent=solvent,
|
||||
@@ -929,37 +432,31 @@ def dissolve_with_heating(G: nx.DiGraph, vessel: dict, solvent: str, volume: Uni
|
||||
# 测试函数
|
||||
def test_dissolve_protocol():
|
||||
"""测试溶解协议的各种参数解析"""
|
||||
debug_print("=== DISSOLVE PROTOCOL 增强版测试 ===")
|
||||
|
||||
# 测试体积解析
|
||||
debug_print("💧 测试体积解析...")
|
||||
volumes = ["10 mL", "?", 10.0, "1 L", "500 μL"]
|
||||
for vol in volumes:
|
||||
result = parse_volume_input(vol)
|
||||
debug_print(f"📏 体积解析: {vol} → {result}mL")
|
||||
|
||||
debug_print(f"体积解析: {vol} → {result}mL")
|
||||
|
||||
# 测试质量解析
|
||||
debug_print("⚖️ 测试质量解析...")
|
||||
masses = ["2.9 g", "?", 2.5, "500 mg"]
|
||||
for mass in masses:
|
||||
result = parse_mass_input(mass)
|
||||
debug_print(f"⚖️ 质量解析: {mass} → {result}g")
|
||||
|
||||
debug_print(f"质量解析: {mass} → {result}g")
|
||||
|
||||
# 测试温度解析
|
||||
debug_print("🌡️ 测试温度解析...")
|
||||
temps = ["60 °C", "room temperature", "?", 25.0, "reflux"]
|
||||
for temp in temps:
|
||||
result = parse_temperature_input(temp)
|
||||
debug_print(f"🌡️ 温度解析: {temp} → {result}°C")
|
||||
|
||||
debug_print(f"温度解析: {temp} → {result}°C")
|
||||
|
||||
# 测试时间解析
|
||||
debug_print("⏱️ 测试时间解析...")
|
||||
times = ["30 min", "1 h", "?", 60.0]
|
||||
for time in times:
|
||||
result = parse_time_input(time)
|
||||
debug_print(f"⏱️ 时间解析: {time} → {result}s")
|
||||
|
||||
debug_print("✅ 测试完成")
|
||||
debug_print(f"时间解析: {time} → {result}s")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dissolve_protocol()
|
||||
@@ -1,87 +1,40 @@
|
||||
import networkx as nx
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
|
||||
def find_connected_heater(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""
|
||||
查找与容器相连的加热器
|
||||
|
||||
Args:
|
||||
G: 网络图
|
||||
vessel: 容器名称
|
||||
|
||||
Returns:
|
||||
str: 加热器ID,如果没有则返回None
|
||||
"""
|
||||
print(f"DRY: 正在查找与容器 '{vessel}' 相连的加热器...")
|
||||
|
||||
# 查找所有加热器节点
|
||||
heater_nodes = [node for node in G.nodes()
|
||||
if ('heater' in node.lower() or
|
||||
'heat' in node.lower() or
|
||||
G.nodes[node].get('class') == 'virtual_heatchill' or
|
||||
G.nodes[node].get('type') == 'heater')]
|
||||
|
||||
print(f"DRY: 找到的加热器节点: {heater_nodes}")
|
||||
|
||||
# 检查是否有加热器与目标容器相连
|
||||
for heater in heater_nodes:
|
||||
if G.has_edge(heater, vessel) or G.has_edge(vessel, heater):
|
||||
print(f"DRY: 找到与容器 '{vessel}' 相连的加热器: {heater}")
|
||||
return heater
|
||||
|
||||
# 如果没有直接连接,查找距离最近的加热器
|
||||
for heater in heater_nodes:
|
||||
try:
|
||||
path = nx.shortest_path(G, source=heater, target=vessel)
|
||||
if len(path) <= 3: # 最多2个中间节点
|
||||
print(f"DRY: 找到距离较近的加热器: {heater}, 路径: {' → '.join(path)}")
|
||||
return heater
|
||||
except nx.NetworkXNoPath:
|
||||
continue
|
||||
|
||||
print(f"DRY: 未找到与容器 '{vessel}' 相连的加热器")
|
||||
return None
|
||||
from .utils.vessel_parser import get_vessel, find_connected_heatchill
|
||||
from .utils.logger_util import debug_print
|
||||
|
||||
|
||||
def generate_dry_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
compound: str = "", # 🔧 修改:参数顺序调整,并设置默认值
|
||||
**kwargs # 接收其他可能的参数但不使用
|
||||
vessel: dict,
|
||||
compound: str = "",
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成干燥协议序列
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为容器和设备
|
||||
vessel: 目标容器字典(从XDL传入)
|
||||
compound: 化合物名称(从XDL传入,可选)
|
||||
**kwargs: 其他可选参数,但不使用
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# 默认参数
|
||||
dry_temp = 60.0 # 默认干燥温度 60°C
|
||||
dry_time = 3600.0 # 默认干燥时间 1小时(3600秒)
|
||||
simulation_time = 60.0 # 模拟时间 1分钟
|
||||
|
||||
print(f"🌡️ DRY: 开始生成干燥协议 ✨")
|
||||
print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
print(f" 🧪 化合物: {compound or '未指定'}")
|
||||
print(f" 🔥 干燥温度: {dry_temp}°C")
|
||||
print(f" ⏰ 干燥时间: {dry_time/60:.0f} 分钟")
|
||||
|
||||
# 🔧 新增:记录干燥前的容器状态
|
||||
print(f"🔍 记录干燥前容器状态...")
|
||||
dry_temp = 60.0
|
||||
dry_time = 3600.0
|
||||
simulation_time = 60.0
|
||||
|
||||
debug_print(f"开始生成干燥协议: vessel={vessel_id}, compound={compound or '未指定'}, temp={dry_temp}°C")
|
||||
|
||||
# 记录干燥前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -89,39 +42,30 @@ def generate_dry_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
print(f"📊 干燥前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
print(f"\n📋 步骤1: 验证目标容器 '{vessel_id}' 是否存在...")
|
||||
if vessel_id not in G.nodes():
|
||||
print(f"⚠️ DRY: 警告 - 容器 '{vessel_id}' 不存在于系统中,跳过干燥 😢")
|
||||
debug_print(f"容器 '{vessel_id}' 不存在于系统中,跳过干燥")
|
||||
return action_sequence
|
||||
print(f"✅ 容器 '{vessel_id}' 验证通过!")
|
||||
|
||||
|
||||
# 2. 查找相连的加热器
|
||||
print(f"\n🔍 步骤2: 查找与容器相连的加热器...")
|
||||
heater_id = find_connected_heater(G, vessel_id) # 🔧 使用 vessel_id
|
||||
|
||||
heater_id = find_connected_heatchill(G, vessel_id)
|
||||
|
||||
if heater_id is None:
|
||||
print(f"😭 DRY: 警告 - 未找到与容器 '{vessel_id}' 相连的加热器,跳过干燥")
|
||||
print(f"🎭 添加模拟干燥动作...")
|
||||
# 添加一个等待动作,表示干燥过程(模拟)
|
||||
debug_print(f"未找到与容器 '{vessel_id}' 相连的加热器,添加模拟干燥动作")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 10.0, # 模拟等待时间
|
||||
"time": 10.0,
|
||||
"description": f"模拟干燥 {compound or '化合物'} (无加热器可用)"
|
||||
}
|
||||
})
|
||||
|
||||
# 🔧 新增:模拟干燥的体积变化(溶剂蒸发)
|
||||
print(f"🔧 模拟干燥过程的体积减少...")
|
||||
|
||||
# 模拟干燥的体积变化
|
||||
if original_liquid_volume > 0:
|
||||
# 假设干燥过程中损失10%的体积(溶剂蒸发)
|
||||
volume_loss = original_liquid_volume * 0.1
|
||||
new_volume = max(0.0, original_liquid_volume - volume_loss)
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -133,15 +77,14 @@ def generate_dry_protocol(
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
@@ -149,33 +92,27 @@ def generate_dry_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
print(f"📊 模拟干燥体积变化: {original_liquid_volume:.2f}mL → {new_volume:.2f}mL (-{volume_loss:.2f}mL)")
|
||||
|
||||
print(f"📄 DRY: 协议生成完成,共 {len(action_sequence)} 个动作 🎯")
|
||||
|
||||
debug_print(f"模拟干燥体积变化: {original_liquid_volume:.2f}mL -> {new_volume:.2f}mL")
|
||||
|
||||
debug_print(f"协议生成完成,共 {len(action_sequence)} 个动作")
|
||||
return action_sequence
|
||||
|
||||
print(f"🎉 找到加热器: {heater_id}!")
|
||||
|
||||
|
||||
debug_print(f"找到加热器: {heater_id}")
|
||||
|
||||
# 3. 启动加热器进行干燥
|
||||
print(f"\n🚀 步骤3: 开始执行干燥流程...")
|
||||
print(f"🔥 启动加热器 {heater_id} 进行干燥")
|
||||
|
||||
# 3.1 启动加热
|
||||
print(f" ⚡ 动作1: 启动加热到 {dry_temp}°C...")
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": dry_temp,
|
||||
"purpose": f"干燥 {compound or '化合物'}"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 加热器启动命令已添加 🔥")
|
||||
|
||||
|
||||
# 3.2 等待温度稳定
|
||||
print(f" ⏳ 动作2: 等待温度稳定...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -183,34 +120,27 @@ def generate_dry_protocol(
|
||||
"description": f"等待温度稳定到 {dry_temp}°C"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 温度稳定等待命令已添加 🌡️")
|
||||
|
||||
|
||||
# 3.3 保持干燥温度
|
||||
print(f" 🔄 动作3: 保持干燥温度 {simulation_time/60:.0f} 分钟...")
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": dry_temp,
|
||||
"time": simulation_time,
|
||||
"purpose": f"干燥 {compound or '化合物'},保持温度 {dry_temp}°C"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 温度保持命令已添加 🌡️⏰")
|
||||
|
||||
# 🔧 新增:干燥过程中的体积变化计算
|
||||
print(f"🔧 计算干燥过程中的体积变化...")
|
||||
|
||||
# 干燥过程中的体积变化计算
|
||||
if original_liquid_volume > 0:
|
||||
# 干燥过程中,溶剂会蒸发,固体保留
|
||||
# 根据温度和时间估算蒸发量
|
||||
evaporation_rate = 0.001 * dry_temp # 每秒每°C蒸发0.001mL
|
||||
total_evaporation = min(original_liquid_volume * 0.8,
|
||||
evaporation_rate * simulation_time) # 最多蒸发80%
|
||||
|
||||
evaporation_rate = 0.001 * dry_temp
|
||||
total_evaporation = min(original_liquid_volume * 0.8,
|
||||
evaporation_rate * simulation_time)
|
||||
|
||||
new_volume = max(0.0, original_liquid_volume - total_evaporation)
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -222,15 +152,14 @@ def generate_dry_protocol(
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
@@ -238,37 +167,29 @@ def generate_dry_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
print(f"📊 干燥体积变化计算:")
|
||||
print(f" - 初始体积: {original_liquid_volume:.2f}mL")
|
||||
print(f" - 蒸发量: {total_evaporation:.2f}mL")
|
||||
print(f" - 剩余体积: {new_volume:.2f}mL")
|
||||
print(f" - 蒸发率: {(total_evaporation/original_liquid_volume*100):.1f}%")
|
||||
|
||||
|
||||
debug_print(f"干燥体积变化: {original_liquid_volume:.2f}mL -> {new_volume:.2f}mL (-{total_evaporation:.2f}mL)")
|
||||
|
||||
# 3.4 停止加热
|
||||
print(f" ⏹️ 动作4: 停止加热...")
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"purpose": f"干燥完成,停止加热"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 停止加热命令已添加 🛑")
|
||||
|
||||
|
||||
# 3.5 等待冷却
|
||||
print(f" ❄️ 动作5: 等待冷却...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 10.0, # 等待10秒冷却
|
||||
"time": 10.0,
|
||||
"description": f"等待 {compound or '化合物'} 冷却"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 冷却等待命令已添加 🧊")
|
||||
|
||||
# 🔧 新增:干燥完成后的状态报告
|
||||
|
||||
# 最终状态
|
||||
final_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -276,60 +197,37 @@ def generate_dry_protocol(
|
||||
final_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
final_liquid_volume = current_volume
|
||||
|
||||
print(f"\n🎊 DRY: 协议生成完成,共 {len(action_sequence)} 个动作 🎯")
|
||||
print(f"⏱️ DRY: 预计总时间: {(simulation_time + 30)/60:.0f} 分钟 ⌛")
|
||||
print(f"📊 干燥结果:")
|
||||
print(f" - 容器: {vessel_id}")
|
||||
print(f" - 化合物: {compound or '未指定'}")
|
||||
print(f" - 干燥前体积: {original_liquid_volume:.2f}mL")
|
||||
print(f" - 干燥后体积: {final_liquid_volume:.2f}mL")
|
||||
print(f" - 蒸发体积: {(original_liquid_volume - final_liquid_volume):.2f}mL")
|
||||
print(f"🏁 所有动作序列准备就绪! ✨")
|
||||
|
||||
|
||||
debug_print(f"干燥协议生成完成: {len(action_sequence)} 个动作, 体积 {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
# 🔧 新增:便捷函数
|
||||
def generate_quick_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
# 便捷函数
|
||||
def generate_quick_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
temp: float = 40.0, time: float = 30.0) -> List[Dict[str, Any]]:
|
||||
"""快速干燥:低温短时间"""
|
||||
vessel_id = vessel["id"]
|
||||
print(f"🌡️ 快速干燥: {compound or '化合物'} → {vessel_id} @ {temp}°C ({time}min)")
|
||||
|
||||
# 临时修改默认参数
|
||||
import types
|
||||
temp_func = types.FunctionType(
|
||||
generate_dry_protocol.__code__,
|
||||
generate_dry_protocol.__globals__
|
||||
)
|
||||
|
||||
# 直接调用原函数,但修改内部参数
|
||||
return generate_dry_protocol(G, vessel, compound)
|
||||
|
||||
|
||||
def generate_thorough_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
def generate_thorough_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
temp: float = 80.0, time: float = 120.0) -> List[Dict[str, Any]]:
|
||||
"""深度干燥:高温长时间"""
|
||||
vessel_id = vessel["id"]
|
||||
print(f"🔥 深度干燥: {compound or '化合物'} → {vessel_id} @ {temp}°C ({time}min)")
|
||||
return generate_dry_protocol(G, vessel, compound)
|
||||
|
||||
|
||||
def generate_gentle_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
def generate_gentle_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
temp: float = 30.0, time: float = 180.0) -> List[Dict[str, Any]]:
|
||||
"""温和干燥:低温长时间"""
|
||||
vessel_id = vessel["id"]
|
||||
print(f"🌡️ 温和干燥: {compound or '化合物'} → {vessel_id} @ {temp}°C ({time}min)")
|
||||
return generate_dry_protocol(G, vessel, compound)
|
||||
|
||||
|
||||
# 测试函数
|
||||
def test_dry_protocol():
|
||||
"""测试干燥协议"""
|
||||
print("=== DRY PROTOCOL 测试 ===")
|
||||
print("测试完成")
|
||||
debug_print("=== DRY PROTOCOL 测试 ===")
|
||||
debug_print("测试完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dry_protocol()
|
||||
test_dry_protocol()
|
||||
|
||||
@@ -3,38 +3,14 @@ from functools import partial
|
||||
import networkx as nx
|
||||
import logging
|
||||
import uuid
|
||||
import sys
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import action_log
|
||||
from .utils.vessel_parser import get_vessel, find_connected_stirrer
|
||||
from .utils.logger_util import debug_print, action_log
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing, generate_pump_protocol
|
||||
|
||||
# 设置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 确保输出编码为UTF-8
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出函数 - 支持中文"""
|
||||
try:
|
||||
# 确保消息是字符串格式
|
||||
safe_message = str(message)
|
||||
logger.info(f"[抽真空充气] {safe_message}")
|
||||
except UnicodeEncodeError:
|
||||
# 如果编码失败,尝试替换不支持的字符
|
||||
safe_message = str(message).encode('utf-8', errors='replace').decode('utf-8')
|
||||
logger.info(f"[抽真空充气] {safe_message}")
|
||||
except Exception as e:
|
||||
# 最后的安全措施
|
||||
fallback_message = f"日志输出错误: {repr(message)}"
|
||||
logger.info(f"[抽真空充气] {fallback_message}")
|
||||
|
||||
create_action_log = partial(action_log, prefix="[抽真空充气]")
|
||||
|
||||
def find_gas_source(G: nx.DiGraph, gas: str) -> str:
|
||||
@@ -44,10 +20,9 @@ def find_gas_source(G: nx.DiGraph, gas: str) -> str:
|
||||
2. 气体类型匹配(data.gas_type)
|
||||
3. 默认气源
|
||||
"""
|
||||
debug_print(f"🔍 正在查找气体 '{gas}' 的气源...")
|
||||
|
||||
# 第一步:通过容器名称匹配
|
||||
debug_print(f"📋 方法1: 容器名称匹配...")
|
||||
debug_print(f"正在查找气体 '{gas}' 的气源...")
|
||||
|
||||
# 通过容器名称匹配
|
||||
gas_source_patterns = [
|
||||
f"gas_source_{gas}",
|
||||
f"gas_{gas}",
|
||||
@@ -57,254 +32,178 @@ def find_gas_source(G: nx.DiGraph, gas: str) -> str:
|
||||
f"reagent_bottle_{gas}",
|
||||
f"bottle_{gas}"
|
||||
]
|
||||
|
||||
debug_print(f"🎯 尝试的容器名称: {gas_source_patterns}")
|
||||
|
||||
|
||||
for pattern in gas_source_patterns:
|
||||
if pattern in G.nodes():
|
||||
debug_print(f"✅ 通过名称找到气源: {pattern}")
|
||||
debug_print(f"通过名称找到气源: {pattern}")
|
||||
return pattern
|
||||
|
||||
# 第二步:通过气体类型匹配 (data.gas_type)
|
||||
debug_print(f"📋 方法2: 气体类型匹配...")
|
||||
|
||||
# 通过气体类型匹配 (data.gas_type)
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
# 检查是否是气源设备
|
||||
if ('gas_source' in node_class or
|
||||
'gas' in node_id.lower() or
|
||||
|
||||
if ('gas_source' in node_class or
|
||||
'gas' in node_id.lower() or
|
||||
node_id.startswith('flask_')):
|
||||
|
||||
# 检查 data.gas_type
|
||||
|
||||
data = node_data.get('data', {})
|
||||
gas_type = data.get('gas_type', '')
|
||||
|
||||
|
||||
if gas_type.lower() == gas.lower():
|
||||
debug_print(f"✅ 通过气体类型找到气源: {node_id} (气体类型: {gas_type})")
|
||||
debug_print(f"通过气体类型找到气源: {node_id} (气体类型: {gas_type})")
|
||||
return node_id
|
||||
|
||||
# 检查 config.gas_type
|
||||
|
||||
config = node_data.get('config', {})
|
||||
config_gas_type = config.get('gas_type', '')
|
||||
|
||||
|
||||
if config_gas_type.lower() == gas.lower():
|
||||
debug_print(f"✅ 通过配置气体类型找到气源: {node_id} (配置气体类型: {config_gas_type})")
|
||||
debug_print(f"通过配置气体类型找到气源: {node_id} (配置气体类型: {config_gas_type})")
|
||||
return node_id
|
||||
|
||||
# 第三步:查找所有可用的气源设备
|
||||
debug_print(f"📋 方法3: 查找可用气源...")
|
||||
|
||||
# 查找所有可用的气源设备
|
||||
available_gas_sources = []
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if ('gas_source' in node_class or
|
||||
|
||||
if ('gas_source' in node_class or
|
||||
'gas' in node_id.lower() or
|
||||
(node_id.startswith('flask_') and any(g in node_id.lower() for g in ['air', 'nitrogen', 'argon']))):
|
||||
|
||||
|
||||
data = node_data.get('data', {})
|
||||
gas_type = data.get('gas_type', '未知')
|
||||
available_gas_sources.append(f"{node_id} (气体类型: {gas_type})")
|
||||
|
||||
debug_print(f"📊 可用气源: {available_gas_sources}")
|
||||
|
||||
# 第四步:如果找不到特定气体,使用默认的第一个气源
|
||||
debug_print(f"📋 方法4: 查找默认气源...")
|
||||
|
||||
# 如果找不到特定气体,使用默认的第一个气源
|
||||
default_gas_sources = [
|
||||
node for node in G.nodes()
|
||||
node for node in G.nodes()
|
||||
if ((G.nodes[node].get('class') or '').find('virtual_gas_source') != -1
|
||||
or 'gas_source' in node)
|
||||
]
|
||||
|
||||
|
||||
if default_gas_sources:
|
||||
default_source = default_gas_sources[0]
|
||||
debug_print(f"⚠️ 未找到特定气体 '{gas}',使用默认气源: {default_source}")
|
||||
debug_print(f"未找到特定气体 '{gas}',使用默认气源: {default_source}")
|
||||
return default_source
|
||||
|
||||
debug_print(f"❌ 所有方法都失败了!")
|
||||
|
||||
raise ValueError(f"无法找到气体 '{gas}' 的气源。可用气源: {available_gas_sources}")
|
||||
|
||||
def find_vacuum_pump(G: nx.DiGraph) -> str:
|
||||
"""查找真空泵设备"""
|
||||
debug_print("🔍 正在查找真空泵...")
|
||||
|
||||
vacuum_pumps = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if ('virtual_vacuum_pump' in node_class or
|
||||
'vacuum_pump' in node.lower() or
|
||||
|
||||
if ('virtual_vacuum_pump' in node_class or
|
||||
'vacuum_pump' in node.lower() or
|
||||
'vacuum' in node_class.lower()):
|
||||
vacuum_pumps.append(node)
|
||||
debug_print(f"📋 发现真空泵: {node}")
|
||||
|
||||
if not vacuum_pumps:
|
||||
debug_print(f"❌ 系统中未找到真空泵")
|
||||
raise ValueError("系统中未找到真空泵")
|
||||
|
||||
debug_print(f"✅ 使用真空泵: {vacuum_pumps[0]}")
|
||||
return vacuum_pumps[0]
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str) -> Optional[str]:
|
||||
"""查找与指定容器相连的搅拌器"""
|
||||
debug_print(f"🔍 正在查找与容器 {vessel} 连接的搅拌器...")
|
||||
|
||||
stirrer_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if 'virtual_stirrer' in node_class or 'stirrer' in node.lower():
|
||||
stirrer_nodes.append(node)
|
||||
debug_print(f"📋 发现搅拌器: {node}")
|
||||
|
||||
debug_print(f"📊 找到的搅拌器总数: {len(stirrer_nodes)}")
|
||||
|
||||
# 检查哪个搅拌器与目标容器相连
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 找到连接的搅拌器: {stirrer}")
|
||||
return stirrer
|
||||
|
||||
# 如果没有连接的搅拌器,返回第一个可用的
|
||||
if stirrer_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的搅拌器,使用第一个可用的: {stirrer_nodes[0]}")
|
||||
return stirrer_nodes[0]
|
||||
|
||||
debug_print("❌ 未找到搅拌器")
|
||||
return None
|
||||
if not vacuum_pumps:
|
||||
raise ValueError("系统中未找到真空泵")
|
||||
|
||||
debug_print(f"使用真空泵: {vacuum_pumps[0]}")
|
||||
return vacuum_pumps[0]
|
||||
|
||||
def find_vacuum_solenoid_valve(G: nx.DiGraph, vacuum_pump: str) -> Optional[str]:
|
||||
"""查找真空泵相关的电磁阀"""
|
||||
debug_print(f"🔍 正在查找真空泵 {vacuum_pump} 的电磁阀...")
|
||||
|
||||
# 查找所有电磁阀
|
||||
solenoid_valves = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if ('solenoid' in node_class.lower() or 'solenoid_valve' in node.lower()):
|
||||
solenoid_valves.append(node)
|
||||
debug_print(f"📋 发现电磁阀: {node}")
|
||||
|
||||
debug_print(f"📊 找到的电磁阀: {solenoid_valves}")
|
||||
|
||||
|
||||
# 检查连接关系
|
||||
debug_print(f"📋 方法1: 检查连接关系...")
|
||||
for solenoid in solenoid_valves:
|
||||
if G.has_edge(solenoid, vacuum_pump) or G.has_edge(vacuum_pump, solenoid):
|
||||
debug_print(f"✅ 找到连接的真空电磁阀: {solenoid}")
|
||||
debug_print(f"找到连接的真空电磁阀: {solenoid}")
|
||||
return solenoid
|
||||
|
||||
|
||||
# 通过命名规则查找
|
||||
debug_print(f"📋 方法2: 检查命名规则...")
|
||||
for solenoid in solenoid_valves:
|
||||
if 'vacuum' in solenoid.lower() or solenoid == 'solenoid_valve_1':
|
||||
debug_print(f"✅ 通过命名找到真空电磁阀: {solenoid}")
|
||||
debug_print(f"通过命名找到真空电磁阀: {solenoid}")
|
||||
return solenoid
|
||||
|
||||
debug_print("⚠️ 未找到真空电磁阀")
|
||||
|
||||
debug_print("未找到真空电磁阀")
|
||||
return None
|
||||
|
||||
def find_gas_solenoid_valve(G: nx.DiGraph, gas_source: str) -> Optional[str]:
|
||||
"""查找气源相关的电磁阀"""
|
||||
debug_print(f"🔍 正在查找气源 {gas_source} 的电磁阀...")
|
||||
|
||||
# 查找所有电磁阀
|
||||
solenoid_valves = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if ('solenoid' in node_class.lower() or 'solenoid_valve' in node.lower()):
|
||||
solenoid_valves.append(node)
|
||||
|
||||
debug_print(f"📊 找到的电磁阀: {solenoid_valves}")
|
||||
|
||||
|
||||
# 检查连接关系
|
||||
debug_print(f"📋 方法1: 检查连接关系...")
|
||||
for solenoid in solenoid_valves:
|
||||
if G.has_edge(gas_source, solenoid) or G.has_edge(solenoid, gas_source):
|
||||
debug_print(f"✅ 找到连接的气源电磁阀: {solenoid}")
|
||||
debug_print(f"找到连接的气源电磁阀: {solenoid}")
|
||||
return solenoid
|
||||
|
||||
|
||||
# 通过命名规则查找
|
||||
debug_print(f"📋 方法2: 检查命名规则...")
|
||||
for solenoid in solenoid_valves:
|
||||
if 'gas' in solenoid.lower() or solenoid == 'solenoid_valve_2':
|
||||
debug_print(f"✅ 通过命名找到气源电磁阀: {solenoid}")
|
||||
debug_print(f"通过命名找到气源电磁阀: {solenoid}")
|
||||
return solenoid
|
||||
|
||||
debug_print("⚠️ 未找到气源电磁阀")
|
||||
|
||||
debug_print("未找到气源电磁阀")
|
||||
return None
|
||||
|
||||
def generate_evacuateandrefill_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
gas: str,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成抽真空和充气操作的动作序列 - 中文版
|
||||
|
||||
生成抽真空和充气操作的动作序列
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 目标容器字典(必需)
|
||||
gas: 气体名称(必需)
|
||||
gas: 气体名称(必需)
|
||||
**kwargs: 其他参数(兼容性)
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
# 硬编码重复次数为 3
|
||||
repeats = 3
|
||||
|
||||
# 生成协议ID
|
||||
|
||||
protocol_id = str(uuid.uuid4())
|
||||
debug_print(f"🆔 生成协议ID: {protocol_id}")
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print("🧪 开始生成抽真空充气协议")
|
||||
debug_print(f"📋 原始参数:")
|
||||
debug_print(f" 🥼 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 💨 气体: '{gas}'")
|
||||
debug_print(f" 🔄 循环次数: {repeats} (硬编码)")
|
||||
debug_print(f" 📦 其他参数: {kwargs}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
|
||||
debug_print(f"开始生成抽真空充气协议: vessel={vessel_id}, gas={gas}, repeats={repeats}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# === 参数验证和修正 ===
|
||||
debug_print("🔍 步骤1: 参数验证和修正...")
|
||||
action_sequence.append(create_action_log(f"开始抽真空充气操作 - 容器: {vessel_id}", "🎬"))
|
||||
action_sequence.append(create_action_log(f"目标气体: {gas}", "💨"))
|
||||
action_sequence.append(create_action_log(f"循环次数: {repeats}", "🔄"))
|
||||
|
||||
# 验证必需参数
|
||||
|
||||
if not vessel_id:
|
||||
debug_print("❌ 容器参数不能为空")
|
||||
raise ValueError("容器参数不能为空")
|
||||
|
||||
|
||||
if not gas:
|
||||
debug_print("❌ 气体参数不能为空")
|
||||
raise ValueError("气体参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print(f"❌ 容器 '{vessel_id}' 在系统中不存在")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"容器 '{vessel_id}' 在系统中不存在")
|
||||
|
||||
debug_print("✅ 基本参数验证通过")
|
||||
|
||||
action_sequence.append(create_action_log("参数验证通过", "✅"))
|
||||
|
||||
|
||||
# 标准化气体名称
|
||||
debug_print("🔧 标准化气体名称...")
|
||||
gas_aliases = {
|
||||
'n2': 'nitrogen',
|
||||
'ar': 'argon',
|
||||
@@ -319,61 +218,54 @@ def generate_evacuateandrefill_protocol(
|
||||
'二氧化碳': 'carbon_dioxide',
|
||||
'氢气': 'hydrogen'
|
||||
}
|
||||
|
||||
|
||||
original_gas = gas
|
||||
gas_lower = gas.lower().strip()
|
||||
if gas_lower in gas_aliases:
|
||||
gas = gas_aliases[gas_lower]
|
||||
debug_print(f"🔄 标准化气体名称: {original_gas} -> {gas}")
|
||||
debug_print(f"标准化气体名称: {original_gas} -> {gas}")
|
||||
action_sequence.append(create_action_log(f"气体名称标准化: {original_gas} -> {gas}", "🔄"))
|
||||
|
||||
debug_print(f"📋 最终参数: 容器={vessel_id}, 气体={gas}, 重复={repeats}")
|
||||
|
||||
|
||||
debug_print(f"最终参数: 容器={vessel_id}, 气体={gas}, 重复={repeats}")
|
||||
|
||||
# === 查找设备 ===
|
||||
debug_print("🔍 步骤2: 查找设备...")
|
||||
action_sequence.append(create_action_log("正在查找相关设备...", "🔍"))
|
||||
|
||||
|
||||
try:
|
||||
vacuum_pump = find_vacuum_pump(G)
|
||||
action_sequence.append(create_action_log(f"找到真空泵: {vacuum_pump}", "🌪️"))
|
||||
|
||||
|
||||
gas_source = find_gas_source(G, gas)
|
||||
action_sequence.append(create_action_log(f"找到气源: {gas_source}", "💨"))
|
||||
|
||||
|
||||
vacuum_solenoid = find_vacuum_solenoid_valve(G, vacuum_pump)
|
||||
if vacuum_solenoid:
|
||||
action_sequence.append(create_action_log(f"找到真空电磁阀: {vacuum_solenoid}", "🚪"))
|
||||
else:
|
||||
action_sequence.append(create_action_log("未找到真空电磁阀", "⚠️"))
|
||||
|
||||
|
||||
gas_solenoid = find_gas_solenoid_valve(G, gas_source)
|
||||
if gas_solenoid:
|
||||
action_sequence.append(create_action_log(f"找到气源电磁阀: {gas_solenoid}", "🚪"))
|
||||
else:
|
||||
action_sequence.append(create_action_log("未找到气源电磁阀", "⚠️"))
|
||||
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id) # 🔧 使用 vessel_id
|
||||
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
if stirrer_id:
|
||||
action_sequence.append(create_action_log(f"找到搅拌器: {stirrer_id}", "🌪️"))
|
||||
else:
|
||||
action_sequence.append(create_action_log("未找到搅拌器", "⚠️"))
|
||||
|
||||
debug_print(f"📊 设备配置:")
|
||||
debug_print(f" 🌪️ 真空泵: {vacuum_pump}")
|
||||
debug_print(f" 💨 气源: {gas_source}")
|
||||
debug_print(f" 🚪 真空电磁阀: {vacuum_solenoid}")
|
||||
debug_print(f" 🚪 气源电磁阀: {gas_solenoid}")
|
||||
debug_print(f" 🌪️ 搅拌器: {stirrer_id}")
|
||||
|
||||
|
||||
debug_print(f"设备配置: 真空泵={vacuum_pump}, 气源={gas_source}, 搅拌器={stirrer_id}")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 设备查找失败: {str(e)}")
|
||||
debug_print(f"设备查找失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"设备查找失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"设备查找失败: {str(e)}")
|
||||
|
||||
|
||||
# === 参数设置 ===
|
||||
debug_print("🔍 步骤3: 参数设置...")
|
||||
action_sequence.append(create_action_log("设置操作参数...", "⚙️"))
|
||||
|
||||
|
||||
# 根据气体类型调整参数
|
||||
if gas.lower() in ['nitrogen', 'argon']:
|
||||
VACUUM_VOLUME = 25.0
|
||||
@@ -381,7 +273,6 @@ def generate_evacuateandrefill_protocol(
|
||||
PUMP_FLOW_RATE = 2.0
|
||||
VACUUM_TIME = 30.0
|
||||
REFILL_TIME = 20.0
|
||||
debug_print("💨 惰性气体: 使用标准参数")
|
||||
action_sequence.append(create_action_log("检测到惰性气体,使用标准参数", "💨"))
|
||||
elif gas.lower() in ['air', 'oxygen']:
|
||||
VACUUM_VOLUME = 20.0
|
||||
@@ -389,7 +280,6 @@ def generate_evacuateandrefill_protocol(
|
||||
PUMP_FLOW_RATE = 1.5
|
||||
VACUUM_TIME = 45.0
|
||||
REFILL_TIME = 25.0
|
||||
debug_print("🔥 活性气体: 使用保守参数")
|
||||
action_sequence.append(create_action_log("检测到活性气体,使用保守参数", "🔥"))
|
||||
else:
|
||||
VACUUM_VOLUME = 15.0
|
||||
@@ -397,116 +287,88 @@ def generate_evacuateandrefill_protocol(
|
||||
PUMP_FLOW_RATE = 1.0
|
||||
VACUUM_TIME = 60.0
|
||||
REFILL_TIME = 30.0
|
||||
debug_print("❓ 未知气体: 使用安全参数")
|
||||
action_sequence.append(create_action_log("未知气体类型,使用安全参数", "❓"))
|
||||
|
||||
|
||||
STIR_SPEED = 200.0
|
||||
|
||||
debug_print(f"⚙️ 操作参数:")
|
||||
debug_print(f" 📏 真空体积: {VACUUM_VOLUME}mL")
|
||||
debug_print(f" 📏 充气体积: {REFILL_VOLUME}mL")
|
||||
debug_print(f" ⚡ 泵流速: {PUMP_FLOW_RATE}mL/s")
|
||||
debug_print(f" ⏱️ 真空时间: {VACUUM_TIME}s")
|
||||
debug_print(f" ⏱️ 充气时间: {REFILL_TIME}s")
|
||||
debug_print(f" 🌪️ 搅拌速度: {STIR_SPEED}RPM")
|
||||
|
||||
|
||||
action_sequence.append(create_action_log(f"真空体积: {VACUUM_VOLUME}mL", "📏"))
|
||||
action_sequence.append(create_action_log(f"充气体积: {REFILL_VOLUME}mL", "📏"))
|
||||
action_sequence.append(create_action_log(f"泵流速: {PUMP_FLOW_RATE}mL/s", "⚡"))
|
||||
|
||||
|
||||
# === 路径验证 ===
|
||||
debug_print("🔍 步骤4: 路径验证...")
|
||||
action_sequence.append(create_action_log("验证传输路径...", "🛤️"))
|
||||
|
||||
|
||||
try:
|
||||
# 验证抽真空路径
|
||||
if nx.has_path(G, vessel_id, vacuum_pump): # 🔧 使用 vessel_id
|
||||
if nx.has_path(G, vessel_id, vacuum_pump):
|
||||
vacuum_path = nx.shortest_path(G, source=vessel_id, target=vacuum_pump)
|
||||
debug_print(f"✅ 真空路径: {' -> '.join(vacuum_path)}")
|
||||
action_sequence.append(create_action_log(f"真空路径: {' -> '.join(vacuum_path)}", "🛤️"))
|
||||
else:
|
||||
debug_print(f"⚠️ 真空路径不存在,继续执行但可能有问题")
|
||||
action_sequence.append(create_action_log("真空路径检查: 路径不存在", "⚠️"))
|
||||
|
||||
# 验证充气路径
|
||||
if nx.has_path(G, gas_source, vessel_id): # 🔧 使用 vessel_id
|
||||
|
||||
if nx.has_path(G, gas_source, vessel_id):
|
||||
gas_path = nx.shortest_path(G, source=gas_source, target=vessel_id)
|
||||
debug_print(f"✅ 气体路径: {' -> '.join(gas_path)}")
|
||||
action_sequence.append(create_action_log(f"气体路径: {' -> '.join(gas_path)}", "🛤️"))
|
||||
else:
|
||||
debug_print(f"⚠️ 气体路径不存在,继续执行但可能有问题")
|
||||
action_sequence.append(create_action_log("气体路径检查: 路径不存在", "⚠️"))
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 路径验证失败: {str(e)},继续执行")
|
||||
action_sequence.append(create_action_log(f"路径验证失败: {str(e)}", "⚠️"))
|
||||
|
||||
|
||||
# === 启动搅拌器 ===
|
||||
debug_print("🔍 步骤5: 启动搅拌器...")
|
||||
|
||||
if stirrer_id:
|
||||
debug_print(f"🌪️ 启动搅拌器: {stirrer_id}")
|
||||
action_sequence.append(create_action_log(f"启动搅拌器 {stirrer_id} (速度: {STIR_SPEED}rpm)", "🌪️"))
|
||||
|
||||
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_speed": STIR_SPEED,
|
||||
"purpose": "抽真空充气前预搅拌"
|
||||
}
|
||||
})
|
||||
|
||||
# 等待搅拌稳定
|
||||
|
||||
action_sequence.append(create_action_log("等待搅拌稳定...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5.0}
|
||||
})
|
||||
else:
|
||||
debug_print("⚠️ 未找到搅拌器,跳过搅拌器启动")
|
||||
action_sequence.append(create_action_log("跳过搅拌器启动", "⏭️"))
|
||||
|
||||
|
||||
# === 执行循环 ===
|
||||
debug_print("🔍 步骤6: 执行抽真空-充气循环...")
|
||||
action_sequence.append(create_action_log(f"开始 {repeats} 次抽真空-充气循环", "🔄"))
|
||||
|
||||
|
||||
for cycle in range(repeats):
|
||||
debug_print(f"=== 第 {cycle+1}/{repeats} 轮循环 ===")
|
||||
action_sequence.append(create_action_log(f"第 {cycle+1}/{repeats} 轮循环开始", "🚀"))
|
||||
|
||||
|
||||
# ============ 抽真空阶段 ============
|
||||
debug_print(f"🌪️ 抽真空阶段开始")
|
||||
action_sequence.append(create_action_log("开始抽真空阶段", "🌪️"))
|
||||
|
||||
|
||||
# 启动真空泵
|
||||
debug_print(f"🔛 启动真空泵: {vacuum_pump}")
|
||||
action_sequence.append(create_action_log(f"启动真空泵: {vacuum_pump}", "🔛"))
|
||||
action_sequence.append({
|
||||
"device_id": vacuum_pump,
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {"string": "ON"}
|
||||
})
|
||||
|
||||
|
||||
# 开启真空电磁阀
|
||||
if vacuum_solenoid:
|
||||
debug_print(f"🚪 打开真空电磁阀: {vacuum_solenoid}")
|
||||
action_sequence.append(create_action_log(f"打开真空电磁阀: {vacuum_solenoid}", "🚪"))
|
||||
action_sequence.append({
|
||||
"device_id": vacuum_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "OPEN"}
|
||||
})
|
||||
|
||||
|
||||
# 抽真空操作
|
||||
debug_print(f"🌪️ 抽真空操作: {vessel_id} -> {vacuum_pump}")
|
||||
action_sequence.append(create_action_log(f"开始抽真空: {vessel_id} -> {vacuum_pump}", "🌪️"))
|
||||
|
||||
|
||||
try:
|
||||
vacuum_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
from_vessel=vessel_id,
|
||||
to_vessel=vacuum_pump,
|
||||
volume=VACUUM_VOLUME,
|
||||
amount="",
|
||||
@@ -519,27 +381,25 @@ def generate_evacuateandrefill_protocol(
|
||||
flowrate=PUMP_FLOW_RATE,
|
||||
transfer_flowrate=PUMP_FLOW_RATE
|
||||
)
|
||||
|
||||
|
||||
if vacuum_transfer_actions:
|
||||
action_sequence.extend(vacuum_transfer_actions)
|
||||
debug_print(f"✅ 添加了 {len(vacuum_transfer_actions)} 个抽真空动作")
|
||||
action_sequence.append(create_action_log(f"抽真空协议完成 ({len(vacuum_transfer_actions)} 个操作)", "✅"))
|
||||
else:
|
||||
debug_print("⚠️ 抽真空协议返回空序列,添加手动动作")
|
||||
action_sequence.append(create_action_log("抽真空协议为空,使用手动等待", "⚠️"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": VACUUM_TIME}
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 抽真空失败: {str(e)}")
|
||||
debug_print(f"抽真空失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"抽真空失败: {str(e)}", "❌"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": VACUUM_TIME}
|
||||
})
|
||||
|
||||
|
||||
# 抽真空后等待
|
||||
wait_minutes = VACUUM_TIME / 60
|
||||
action_sequence.append(create_action_log(f"抽真空后等待 ({wait_minutes:.1f} 分钟)", "⏳"))
|
||||
@@ -547,65 +407,59 @@ def generate_evacuateandrefill_protocol(
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": VACUUM_TIME}
|
||||
})
|
||||
|
||||
|
||||
# 关闭真空电磁阀
|
||||
if vacuum_solenoid:
|
||||
debug_print(f"🚪 关闭真空电磁阀: {vacuum_solenoid}")
|
||||
action_sequence.append(create_action_log(f"关闭真空电磁阀: {vacuum_solenoid}", "🚪"))
|
||||
action_sequence.append({
|
||||
"device_id": vacuum_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "CLOSED"}
|
||||
})
|
||||
|
||||
|
||||
# 关闭真空泵
|
||||
debug_print(f"🔴 停止真空泵: {vacuum_pump}")
|
||||
action_sequence.append(create_action_log(f"停止真空泵: {vacuum_pump}", "🔴"))
|
||||
action_sequence.append({
|
||||
"device_id": vacuum_pump,
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {"string": "OFF"}
|
||||
})
|
||||
|
||||
|
||||
# 阶段间等待
|
||||
action_sequence.append(create_action_log("抽真空阶段完成,短暂等待", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5.0}
|
||||
})
|
||||
|
||||
|
||||
# ============ 充气阶段 ============
|
||||
debug_print(f"💨 充气阶段开始")
|
||||
action_sequence.append(create_action_log("开始气体充气阶段", "💨"))
|
||||
|
||||
|
||||
# 启动气源
|
||||
debug_print(f"🔛 启动气源: {gas_source}")
|
||||
action_sequence.append(create_action_log(f"启动气源: {gas_source}", "🔛"))
|
||||
action_sequence.append({
|
||||
"device_id": gas_source,
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {"string": "ON"}
|
||||
})
|
||||
|
||||
|
||||
# 开启气源电磁阀
|
||||
if gas_solenoid:
|
||||
debug_print(f"🚪 打开气源电磁阀: {gas_solenoid}")
|
||||
action_sequence.append(create_action_log(f"打开气源电磁阀: {gas_solenoid}", "🚪"))
|
||||
action_sequence.append({
|
||||
"device_id": gas_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "OPEN"}
|
||||
})
|
||||
|
||||
|
||||
# 充气操作
|
||||
debug_print(f"💨 充气操作: {gas_source} -> {vessel_id}")
|
||||
action_sequence.append(create_action_log(f"开始气体充气: {gas_source} -> {vessel_id}", "💨"))
|
||||
|
||||
|
||||
try:
|
||||
gas_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=gas_source,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
to_vessel=vessel_id,
|
||||
volume=REFILL_VOLUME,
|
||||
amount="",
|
||||
time=0.0,
|
||||
@@ -617,27 +471,25 @@ def generate_evacuateandrefill_protocol(
|
||||
flowrate=PUMP_FLOW_RATE,
|
||||
transfer_flowrate=PUMP_FLOW_RATE
|
||||
)
|
||||
|
||||
|
||||
if gas_transfer_actions:
|
||||
action_sequence.extend(gas_transfer_actions)
|
||||
debug_print(f"✅ 添加了 {len(gas_transfer_actions)} 个充气动作")
|
||||
action_sequence.append(create_action_log(f"气体充气协议完成 ({len(gas_transfer_actions)} 个操作)", "✅"))
|
||||
else:
|
||||
debug_print("⚠️ 充气协议返回空序列,添加手动动作")
|
||||
action_sequence.append(create_action_log("充气协议为空,使用手动等待", "⚠️"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": REFILL_TIME}
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 气体充气失败: {str(e)}")
|
||||
debug_print(f"气体充气失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"气体充气失败: {str(e)}", "❌"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": REFILL_TIME}
|
||||
})
|
||||
|
||||
|
||||
# 充气后等待
|
||||
refill_wait_minutes = REFILL_TIME / 60
|
||||
action_sequence.append(create_action_log(f"充气后等待 ({refill_wait_minutes:.1f} 分钟)", "⏳"))
|
||||
@@ -645,29 +497,26 @@ def generate_evacuateandrefill_protocol(
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": REFILL_TIME}
|
||||
})
|
||||
|
||||
|
||||
# 关闭气源电磁阀
|
||||
if gas_solenoid:
|
||||
debug_print(f"🚪 关闭气源电磁阀: {gas_solenoid}")
|
||||
action_sequence.append(create_action_log(f"关闭气源电磁阀: {gas_solenoid}", "🚪"))
|
||||
action_sequence.append({
|
||||
"device_id": gas_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "CLOSED"}
|
||||
})
|
||||
|
||||
|
||||
# 关闭气源
|
||||
debug_print(f"🔴 停止气源: {gas_source}")
|
||||
action_sequence.append(create_action_log(f"停止气源: {gas_source}", "🔴"))
|
||||
action_sequence.append({
|
||||
"device_id": gas_source,
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {"string": "OFF"}
|
||||
})
|
||||
|
||||
|
||||
# 循环间等待
|
||||
if cycle < repeats - 1:
|
||||
debug_print(f"⏳ 等待下一个循环...")
|
||||
action_sequence.append(create_action_log("等待下一个循环...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
@@ -675,78 +524,58 @@ def generate_evacuateandrefill_protocol(
|
||||
})
|
||||
else:
|
||||
action_sequence.append(create_action_log(f"第 {cycle+1}/{repeats} 轮循环完成", "✅"))
|
||||
|
||||
|
||||
# === 停止搅拌器 ===
|
||||
debug_print("🔍 步骤7: 停止搅拌器...")
|
||||
|
||||
if stirrer_id:
|
||||
debug_print(f"🛑 停止搅拌器: {stirrer_id}")
|
||||
action_sequence.append(create_action_log(f"停止搅拌器: {stirrer_id}", "🛑"))
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id},} # 🔧 使用 vessel_id
|
||||
"action_kwargs": {"vessel": {"id": vessel_id},}
|
||||
})
|
||||
else:
|
||||
action_sequence.append(create_action_log("跳过搅拌器停止", "⏭️"))
|
||||
|
||||
|
||||
# === 最终等待 ===
|
||||
action_sequence.append(create_action_log("最终稳定等待...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10.0}
|
||||
})
|
||||
|
||||
|
||||
# === 总结 ===
|
||||
total_time = (VACUUM_TIME + REFILL_TIME + 25) * repeats + 20
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 抽真空充气协议生成完成")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)}")
|
||||
debug_print(f" ⏱️ 预计总时间: {total_time:.0f}s ({total_time/60:.1f} 分钟)")
|
||||
debug_print(f" 🥼 处理容器: {vessel_id}")
|
||||
debug_print(f" 💨 使用气体: {gas}")
|
||||
debug_print(f" 🔄 重复次数: {repeats}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
# 添加完成日志
|
||||
|
||||
debug_print(f"抽真空充气协议生成完成: {len(action_sequence)} 个动作, 预计 {total_time:.0f}s")
|
||||
|
||||
summary_msg = f"抽真空充气协议完成: {vessel_id} (使用 {gas},{repeats} 次循环)"
|
||||
action_sequence.append(create_action_log(summary_msg, "🎉"))
|
||||
|
||||
|
||||
return action_sequence
|
||||
|
||||
# === 便捷函数 ===
|
||||
|
||||
def generate_nitrogen_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]: # 🔧 修改参数类型
|
||||
def generate_nitrogen_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""生成氮气置换协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"💨 生成氮气置换协议: {vessel_id}")
|
||||
return generate_evacuateandrefill_protocol(G, vessel, "nitrogen", **kwargs)
|
||||
|
||||
def generate_argon_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]: # 🔧 修改参数类型
|
||||
def generate_argon_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""生成氩气置换协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"💨 生成氩气置换协议: {vessel_id}")
|
||||
return generate_evacuateandrefill_protocol(G, vessel, "argon", **kwargs)
|
||||
|
||||
def generate_air_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]: # 🔧 修改参数类型
|
||||
def generate_air_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""生成空气置换协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"💨 生成空气置换协议: {vessel_id}")
|
||||
return generate_evacuateandrefill_protocol(G, vessel, "air", **kwargs)
|
||||
|
||||
def generate_inert_atmosphere_protocol(G: nx.DiGraph, vessel: dict, gas: str = "nitrogen", **kwargs) -> List[Dict[str, Any]]: # 🔧 修改参数类型
|
||||
def generate_inert_atmosphere_protocol(G: nx.DiGraph, vessel: dict, gas: str = "nitrogen", **kwargs) -> List[Dict[str, Any]]:
|
||||
"""生成惰性气氛协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🛡️ 生成惰性气氛协议: {vessel_id} (使用 {gas})")
|
||||
return generate_evacuateandrefill_protocol(G, vessel, gas, **kwargs)
|
||||
|
||||
# 测试函数
|
||||
def test_evacuateandrefill_protocol():
|
||||
"""测试抽真空充气协议"""
|
||||
debug_print("=== 抽真空充气协议增强中文版测试 ===")
|
||||
debug_print("✅ 测试完成")
|
||||
debug_print("=== 抽真空充气协议测试 ===")
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_evacuateandrefill_protocol()
|
||||
test_evacuateandrefill_protocol()
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# import numpy as np
|
||||
# import networkx as nx
|
||||
|
||||
|
||||
# def generate_evacuateandrefill_protocol(
|
||||
# G: nx.DiGraph,
|
||||
# vessel: str,
|
||||
# gas: str,
|
||||
# repeats: int = 1
|
||||
# ) -> list[dict]:
|
||||
# """
|
||||
# 生成泵操作的动作序列。
|
||||
|
||||
# :param G: 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置
|
||||
# :param from_vessel: 容器A
|
||||
# :param to_vessel: 容器B
|
||||
# :param volume: 转移的体积
|
||||
# :param flowrate: 最终注入容器B时的流速
|
||||
# :param transfer_flowrate: 泵骨架中转移流速(若不指定,默认与注入流速相同)
|
||||
# :return: 泵操作的动作序列
|
||||
# """
|
||||
|
||||
# # 生成电磁阀、真空泵、气源操作的动作序列
|
||||
# vacuum_action_sequence = []
|
||||
# nodes = G.nodes(data=True)
|
||||
|
||||
# # 找到和 vessel 相连的电磁阀和真空泵、气源
|
||||
# vacuum_backbone = {"vessel": vessel}
|
||||
|
||||
# for neighbor in G.neighbors(vessel):
|
||||
# if nodes[neighbor]["class"].startswith("solenoid_valve"):
|
||||
# for neighbor2 in G.neighbors(neighbor):
|
||||
# if neighbor2 == vessel:
|
||||
# continue
|
||||
# if nodes[neighbor2]["class"].startswith("vacuum_pump"):
|
||||
# vacuum_backbone.update({"vacuum_valve": neighbor, "pump": neighbor2})
|
||||
# break
|
||||
# elif nodes[neighbor2]["class"].startswith("gas_source"):
|
||||
# vacuum_backbone.update({"gas_valve": neighbor, "gas": neighbor2})
|
||||
# break
|
||||
# # 判断是否设备齐全
|
||||
# if len(vacuum_backbone) < 5:
|
||||
# print(f"\n\n\n{vacuum_backbone}\n\n\n")
|
||||
# raise ValueError("Not all devices are connected to the vessel.")
|
||||
|
||||
# # 生成操作的动作序列
|
||||
# for i in range(repeats):
|
||||
# # 打开真空泵阀门、关闭气源阀门
|
||||
# vacuum_action_sequence.append([
|
||||
# {
|
||||
# "device_id": vacuum_backbone["vacuum_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "OPEN"
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "CLOSED"
|
||||
# }
|
||||
# }
|
||||
# ])
|
||||
|
||||
# # 打开真空泵、关闭气源
|
||||
# vacuum_action_sequence.append([
|
||||
# {
|
||||
# "device_id": vacuum_backbone["pump"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "ON"
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "OFF"
|
||||
# }
|
||||
# }
|
||||
# ])
|
||||
# vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}})
|
||||
|
||||
# # 关闭真空泵阀门、打开气源阀门
|
||||
# vacuum_action_sequence.append([
|
||||
# {
|
||||
# "device_id": vacuum_backbone["vacuum_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "CLOSED"
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "OPEN"
|
||||
# }
|
||||
# }
|
||||
# ])
|
||||
|
||||
# # 关闭真空泵、打开气源
|
||||
# vacuum_action_sequence.append([
|
||||
# {
|
||||
# "device_id": vacuum_backbone["pump"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "OFF"
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "ON"
|
||||
# }
|
||||
# }
|
||||
# ])
|
||||
# vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}})
|
||||
|
||||
# # 关闭气源
|
||||
# vacuum_action_sequence.append(
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "OFF"
|
||||
# }
|
||||
# }
|
||||
# )
|
||||
|
||||
# # 关闭阀门
|
||||
# vacuum_action_sequence.append(
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "CLOSED"
|
||||
# }
|
||||
# }
|
||||
# )
|
||||
# return vacuum_action_sequence
|
||||
@@ -4,128 +4,99 @@ import logging
|
||||
import re
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.unit_parser import parse_time_input
|
||||
from .utils.logger_util import debug_print
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[EVAPORATE] {message}")
|
||||
|
||||
|
||||
def find_rotavap_device(G: nx.DiGraph, vessel: str = None) -> Optional[str]:
|
||||
"""
|
||||
在组态图中查找旋转蒸发仪设备
|
||||
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 指定的设备名称(可选)
|
||||
|
||||
|
||||
Returns:
|
||||
str: 找到的旋转蒸发仪设备ID,如果没找到返回None
|
||||
"""
|
||||
debug_print("🔍 开始查找旋转蒸发仪设备... 🌪️")
|
||||
|
||||
# 如果指定了vessel,先检查是否存在且是旋转蒸发仪
|
||||
if vessel:
|
||||
debug_print(f"🎯 检查指定设备: {vessel} 🔧")
|
||||
if vessel in G.nodes():
|
||||
node_data = G.nodes[vessel]
|
||||
node_class = node_data.get('class', '')
|
||||
node_type = node_data.get('type', '')
|
||||
|
||||
debug_print(f"📋 设备信息 {vessel}: class={node_class}, type={node_type}")
|
||||
|
||||
# 检查是否为旋转蒸发仪
|
||||
|
||||
if any(keyword in str(node_class).lower() for keyword in ['rotavap', 'rotary', 'evaporat']):
|
||||
debug_print(f"🎉 找到指定的旋转蒸发仪: {vessel} ✨")
|
||||
debug_print(f"找到指定的旋转蒸发仪: {vessel}")
|
||||
return vessel
|
||||
elif node_type == 'device':
|
||||
debug_print(f"✅ 指定设备存在,尝试直接使用: {vessel} 🔧")
|
||||
debug_print(f"指定设备存在,尝试直接使用: {vessel}")
|
||||
return vessel
|
||||
else:
|
||||
debug_print(f"❌ 指定的设备 {vessel} 不存在 😞")
|
||||
|
||||
|
||||
# 在所有设备中查找旋转蒸发仪
|
||||
debug_print("🔎 在所有设备中搜索旋转蒸发仪... 🕵️♀️")
|
||||
rotavap_candidates = []
|
||||
|
||||
|
||||
for node_id, node_data in G.nodes(data=True):
|
||||
node_class = node_data.get('class', '')
|
||||
node_type = node_data.get('type', '')
|
||||
|
||||
# 跳过非设备节点
|
||||
|
||||
if node_type != 'device':
|
||||
continue
|
||||
|
||||
# 检查设备类型
|
||||
|
||||
if any(keyword in str(node_class).lower() for keyword in ['rotavap', 'rotary', 'evaporat']):
|
||||
rotavap_candidates.append(node_id)
|
||||
debug_print(f"🌟 找到旋转蒸发仪候选: {node_id} (class: {node_class}) 🌪️")
|
||||
elif any(keyword in str(node_id).lower() for keyword in ['rotavap', 'rotary', 'evaporat']):
|
||||
rotavap_candidates.append(node_id)
|
||||
debug_print(f"🌟 找到旋转蒸发仪候选 (按名称): {node_id} 🌪️")
|
||||
|
||||
|
||||
if rotavap_candidates:
|
||||
selected = rotavap_candidates[0] # 选择第一个找到的
|
||||
debug_print(f"🎯 选择旋转蒸发仪: {selected} 🏆")
|
||||
selected = rotavap_candidates[0]
|
||||
debug_print(f"选择旋转蒸发仪: {selected}")
|
||||
return selected
|
||||
|
||||
debug_print("😭 未找到旋转蒸发仪设备 💔")
|
||||
|
||||
debug_print("未找到旋转蒸发仪设备")
|
||||
return None
|
||||
|
||||
def find_connected_vessel(G: nx.DiGraph, rotavap_device: str) -> Optional[str]:
|
||||
"""
|
||||
查找与旋转蒸发仪连接的容器
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
rotavap_device: 旋转蒸发仪设备ID
|
||||
|
||||
Returns:
|
||||
str: 连接的容器ID,如果没找到返回None
|
||||
"""
|
||||
debug_print(f"🔗 查找与 {rotavap_device} 连接的容器... 🥽")
|
||||
|
||||
# 查看旋转蒸发仪的子设备
|
||||
rotavap_data = G.nodes[rotavap_device]
|
||||
children = rotavap_data.get('children', [])
|
||||
|
||||
debug_print(f"👶 检查子设备: {children}")
|
||||
|
||||
for child_id in children:
|
||||
if child_id in G.nodes():
|
||||
child_data = G.nodes[child_id]
|
||||
child_type = child_data.get('type', '')
|
||||
|
||||
|
||||
if child_type == 'container':
|
||||
debug_print(f"🎉 找到连接的容器: {child_id} 🥽✨")
|
||||
debug_print(f"找到连接的容器: {child_id}")
|
||||
return child_id
|
||||
|
||||
# 查看邻接的容器
|
||||
debug_print("🤝 检查邻接设备...")
|
||||
|
||||
for neighbor in G.neighbors(rotavap_device):
|
||||
neighbor_data = G.nodes[neighbor]
|
||||
neighbor_type = neighbor_data.get('type', '')
|
||||
|
||||
|
||||
if neighbor_type == 'container':
|
||||
debug_print(f"🎉 找到邻接的容器: {neighbor} 🥽✨")
|
||||
debug_print(f"找到邻接的容器: {neighbor}")
|
||||
return neighbor
|
||||
|
||||
debug_print("😞 未找到连接的容器 💔")
|
||||
|
||||
debug_print("未找到连接的容器")
|
||||
return None
|
||||
|
||||
def generate_evaporate_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
pressure: float = 0.1,
|
||||
temp: float = 60.0,
|
||||
time: Union[str, float] = "180", # 🔧 修改:支持字符串时间
|
||||
time: Union[str, float] = "180",
|
||||
stir_speed: float = 100.0,
|
||||
solvent: str = "",
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成蒸发操作的协议序列 - 支持单位和体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 容器字典(从XDL传入)
|
||||
@@ -135,27 +106,16 @@ def generate_evaporate_protocol(
|
||||
stir_speed: 旋转速度 (RPM),默认100
|
||||
solvent: 溶剂名称(用于参数优化)
|
||||
**kwargs: 其他参数(兼容性)
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
debug_print("🌟" * 20)
|
||||
debug_print("🌪️ 开始生成蒸发协议(支持单位和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 💨 pressure: {pressure} bar")
|
||||
debug_print(f" 🌡️ temp: {temp}°C")
|
||||
debug_print(f" ⏰ time: {time} (类型: {type(time)})")
|
||||
debug_print(f" 🌪️ stir_speed: {stir_speed} RPM")
|
||||
debug_print(f" 🧪 solvent: '{solvent}'")
|
||||
debug_print("🌟" * 20)
|
||||
|
||||
# 🔧 新增:记录蒸发前的容器状态
|
||||
debug_print("🔍 记录蒸发前容器状态...")
|
||||
|
||||
debug_print(f"开始生成蒸发协议: vessel={vessel_id}, pressure={pressure}, temp={temp}, time={time}")
|
||||
|
||||
# 记录蒸发前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -163,168 +123,97 @@ def generate_evaporate_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
debug_print(f"📊 蒸发前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
# === 步骤1: 查找旋转蒸发仪设备 ===
|
||||
debug_print("📍 步骤1: 查找旋转蒸发仪设备... 🔍")
|
||||
|
||||
# 验证vessel参数
|
||||
if not vessel_id:
|
||||
debug_print("❌ vessel 参数不能为空! 😱")
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
|
||||
# 查找旋转蒸发仪设备
|
||||
if not vessel_id:
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
rotavap_device = find_rotavap_device(G, vessel_id)
|
||||
if not rotavap_device:
|
||||
debug_print("💥 未找到旋转蒸发仪设备! 😭")
|
||||
raise ValueError(f"未找到旋转蒸发仪设备。请检查组态图中是否包含 class 包含 'rotavap'、'rotary' 或 'evaporat' 的设备")
|
||||
|
||||
debug_print(f"🎉 成功找到旋转蒸发仪: {rotavap_device} ✨")
|
||||
|
||||
# === 步骤2: 确定目标容器 ===
|
||||
debug_print("📍 步骤2: 确定目标容器... 🥽")
|
||||
|
||||
|
||||
# 确定目标容器
|
||||
target_vessel = vessel_id
|
||||
|
||||
# 如果vessel就是旋转蒸发仪设备,查找连接的容器
|
||||
|
||||
if vessel_id == rotavap_device:
|
||||
debug_print("🔄 vessel就是旋转蒸发仪,查找连接的容器...")
|
||||
connected_vessel = find_connected_vessel(G, rotavap_device)
|
||||
if connected_vessel:
|
||||
target_vessel = connected_vessel
|
||||
debug_print(f"✅ 使用连接的容器: {target_vessel} 🥽✨")
|
||||
else:
|
||||
debug_print(f"⚠️ 未找到连接的容器,使用设备本身: {rotavap_device} 🔧")
|
||||
target_vessel = rotavap_device
|
||||
elif vessel_id in G.nodes() and G.nodes[vessel_id].get('type') == 'container':
|
||||
debug_print(f"✅ 使用指定的容器: {vessel_id} 🥽✨")
|
||||
target_vessel = vessel_id
|
||||
else:
|
||||
debug_print(f"⚠️ 容器 '{vessel_id}' 不存在或类型不正确,使用旋转蒸发仪设备: {rotavap_device} 🔧")
|
||||
target_vessel = rotavap_device
|
||||
|
||||
# === 🔧 新增:步骤3:单位解析处理 ===
|
||||
debug_print("📍 步骤3: 单位解析处理... ⚡")
|
||||
|
||||
# 解析时间
|
||||
|
||||
# 单位解析处理
|
||||
final_time = parse_time_input(time)
|
||||
debug_print(f"🎯 时间解析完成: {time} → {final_time}s ({final_time/60:.1f}分钟) ⏰✨")
|
||||
|
||||
# === 步骤4: 参数验证和修正 ===
|
||||
debug_print("📍 步骤4: 参数验证和修正... 🔧")
|
||||
|
||||
# 修正参数范围
|
||||
debug_print(f"时间解析: {time} -> {final_time}s ({final_time/60:.1f}分钟)")
|
||||
|
||||
# 参数验证和修正
|
||||
if pressure <= 0 or pressure > 1.0:
|
||||
debug_print(f"⚠️ 真空度 {pressure} bar 超出范围,修正为 0.1 bar 💨")
|
||||
pressure = 0.1
|
||||
else:
|
||||
debug_print(f"✅ 真空度 {pressure} bar 在正常范围内 💨")
|
||||
|
||||
|
||||
if temp < 10.0 or temp > 200.0:
|
||||
debug_print(f"⚠️ 温度 {temp}°C 超出范围,修正为 60°C 🌡️")
|
||||
temp = 60.0
|
||||
else:
|
||||
debug_print(f"✅ 温度 {temp}°C 在正常范围内 🌡️")
|
||||
|
||||
|
||||
if final_time <= 0:
|
||||
debug_print(f"⚠️ 时间 {final_time}s 无效,修正为 180s (3分钟) ⏰")
|
||||
final_time = 180.0
|
||||
else:
|
||||
debug_print(f"✅ 时间 {final_time}s ({final_time/60:.1f}分钟) 有效 ⏰")
|
||||
|
||||
|
||||
if stir_speed < 10.0 or stir_speed > 300.0:
|
||||
debug_print(f"⚠️ 旋转速度 {stir_speed} RPM 超出范围,修正为 100 RPM 🌪️")
|
||||
stir_speed = 100.0
|
||||
else:
|
||||
debug_print(f"✅ 旋转速度 {stir_speed} RPM 在正常范围内 🌪️")
|
||||
|
||||
|
||||
# 根据溶剂优化参数
|
||||
if solvent:
|
||||
debug_print(f"🧪 根据溶剂 '{solvent}' 优化参数... 🔬")
|
||||
solvent_lower = solvent.lower()
|
||||
|
||||
|
||||
if any(s in solvent_lower for s in ['water', 'aqueous', 'h2o']):
|
||||
temp = max(temp, 80.0)
|
||||
pressure = max(pressure, 0.2)
|
||||
debug_print("💧 水系溶剂:提高温度和真空度 🌡️💨")
|
||||
elif any(s in solvent_lower for s in ['ethanol', 'methanol', 'acetone']):
|
||||
temp = min(temp, 50.0)
|
||||
pressure = min(pressure, 0.05)
|
||||
debug_print("🍺 易挥发溶剂:降低温度和真空度 🌡️💨")
|
||||
elif any(s in solvent_lower for s in ['dmso', 'dmi', 'toluene']):
|
||||
temp = max(temp, 100.0)
|
||||
pressure = min(pressure, 0.01)
|
||||
debug_print("🔥 高沸点溶剂:提高温度,降低真空度 🌡️💨")
|
||||
else:
|
||||
debug_print("🧪 通用溶剂,使用标准参数 ✨")
|
||||
else:
|
||||
debug_print("🤷♀️ 未指定溶剂,使用默认参数 ✨")
|
||||
|
||||
debug_print(f"🎯 最终参数: pressure={pressure} bar 💨, temp={temp}°C 🌡️, time={final_time}s ⏰, stir_speed={stir_speed} RPM 🌪️")
|
||||
|
||||
# === 🔧 新增:步骤5:蒸发体积计算 ===
|
||||
debug_print("📍 步骤5: 蒸发体积计算... 📊")
|
||||
|
||||
# 根据温度、真空度、时间和溶剂类型估算蒸发量
|
||||
|
||||
debug_print(f"最终参数: pressure={pressure}bar, temp={temp}°C, time={final_time}s, stir_speed={stir_speed}RPM")
|
||||
|
||||
# 蒸发体积计算
|
||||
evaporation_volume = 0.0
|
||||
if original_liquid_volume > 0:
|
||||
# 基础蒸发速率(mL/min)
|
||||
base_evap_rate = 0.5 # 基础速率
|
||||
|
||||
# 温度系数(高温蒸发更快)
|
||||
base_evap_rate = 0.5
|
||||
temp_factor = 1.0 + (temp - 25.0) / 100.0
|
||||
|
||||
# 真空系数(真空度越高蒸发越快)
|
||||
vacuum_factor = 1.0 + (1.0 - pressure) * 2.0
|
||||
|
||||
# 溶剂系数
|
||||
|
||||
solvent_factor = 1.0
|
||||
if solvent:
|
||||
solvent_lower = solvent.lower()
|
||||
if any(s in solvent_lower for s in ['water', 'h2o']):
|
||||
solvent_factor = 0.8 # 水蒸发较慢
|
||||
solvent_factor = 0.8
|
||||
elif any(s in solvent_lower for s in ['ethanol', 'methanol', 'acetone']):
|
||||
solvent_factor = 1.5 # 易挥发溶剂蒸发快
|
||||
solvent_factor = 1.5
|
||||
elif any(s in solvent_lower for s in ['dmso', 'dmi']):
|
||||
solvent_factor = 0.3 # 高沸点溶剂蒸发慢
|
||||
|
||||
# 计算总蒸发量
|
||||
solvent_factor = 0.3
|
||||
|
||||
total_evap_rate = base_evap_rate * temp_factor * vacuum_factor * solvent_factor
|
||||
evaporation_volume = min(
|
||||
original_liquid_volume * 0.95, # 最多蒸发95%
|
||||
total_evap_rate * (final_time / 60.0) # 时间相关的蒸发量
|
||||
original_liquid_volume * 0.95,
|
||||
total_evap_rate * (final_time / 60.0)
|
||||
)
|
||||
|
||||
debug_print(f"📊 蒸发量计算:")
|
||||
debug_print(f" - 基础蒸发速率: {base_evap_rate} mL/min")
|
||||
debug_print(f" - 温度系数: {temp_factor:.2f} (基于 {temp}°C)")
|
||||
debug_print(f" - 真空系数: {vacuum_factor:.2f} (基于 {pressure} bar)")
|
||||
debug_print(f" - 溶剂系数: {solvent_factor:.2f} ({solvent or '通用'})")
|
||||
debug_print(f" - 总蒸发速率: {total_evap_rate:.2f} mL/min")
|
||||
debug_print(f" - 预计蒸发量: {evaporation_volume:.2f}mL ({evaporation_volume/original_liquid_volume*100:.1f}%)")
|
||||
|
||||
# === 步骤6: 生成动作序列 ===
|
||||
debug_print("📍 步骤6: 生成动作序列... 🎬")
|
||||
|
||||
|
||||
debug_print(f"预计蒸发量: {evaporation_volume:.2f}mL ({evaporation_volume/original_liquid_volume*100:.1f}%)")
|
||||
|
||||
# 生成动作序列
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# 1. 等待稳定
|
||||
debug_print(" 🔄 动作1: 添加初始等待稳定... ⏳")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10}
|
||||
})
|
||||
debug_print(" ✅ 初始等待动作已添加 ⏳✨")
|
||||
|
||||
|
||||
# 2. 执行蒸发
|
||||
debug_print(f" 🌪️ 动作2: 执行蒸发操作...")
|
||||
debug_print(f" 🔧 设备: {rotavap_device}")
|
||||
debug_print(f" 🥽 容器: {target_vessel}")
|
||||
debug_print(f" 💨 真空度: {pressure} bar")
|
||||
debug_print(f" 🌡️ 温度: {temp}°C")
|
||||
debug_print(f" ⏰ 时间: {final_time}s ({final_time/60:.1f}分钟)")
|
||||
debug_print(f" 🌪️ 旋转速度: {stir_speed} RPM")
|
||||
|
||||
evaporate_action = {
|
||||
"device_id": rotavap_device,
|
||||
"action_name": "evaporate",
|
||||
@@ -332,20 +221,17 @@ def generate_evaporate_protocol(
|
||||
"vessel": {"id": target_vessel},
|
||||
"pressure": float(pressure),
|
||||
"temp": float(temp),
|
||||
"time": float(final_time), # 🔧 强制转换为float类型
|
||||
"time": float(final_time),
|
||||
"stir_speed": float(stir_speed),
|
||||
"solvent": str(solvent)
|
||||
}
|
||||
}
|
||||
action_sequence.append(evaporate_action)
|
||||
debug_print(" ✅ 蒸发动作已添加 🌪️✨")
|
||||
|
||||
# 🔧 新增:蒸发过程中的体积变化
|
||||
debug_print(" 🔧 更新容器体积 - 蒸发过程...")
|
||||
|
||||
# 蒸发过程中的体积变化
|
||||
if evaporation_volume > 0:
|
||||
new_volume = max(0.0, original_liquid_volume - evaporation_volume)
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -357,15 +243,14 @@ def generate_evaporate_protocol(
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
@@ -373,18 +258,16 @@ def generate_evaporate_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
debug_print(f" 📊 蒸发体积变化: {original_liquid_volume:.2f}mL → {new_volume:.2f}mL (-{evaporation_volume:.2f}mL)")
|
||||
|
||||
|
||||
debug_print(f"蒸发体积变化: {original_liquid_volume:.2f}mL -> {new_volume:.2f}mL (-{evaporation_volume:.2f}mL)")
|
||||
|
||||
# 3. 蒸发后等待
|
||||
debug_print(" 🔄 动作3: 添加蒸发后等待... ⏳")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10}
|
||||
})
|
||||
debug_print(" ✅ 蒸发后等待动作已添加 ⏳✨")
|
||||
|
||||
# 🔧 新增:蒸发完成后的状态报告
|
||||
|
||||
# 最终状态
|
||||
final_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -392,19 +275,7 @@ def generate_evaporate_protocol(
|
||||
final_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
final_liquid_volume = current_volume
|
||||
|
||||
# === 总结 ===
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"🎉 蒸发协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个 📝")
|
||||
debug_print(f"🌪️ 旋转蒸发仪: {rotavap_device} 🔧")
|
||||
debug_print(f"🥽 目标容器: {target_vessel} 🧪")
|
||||
debug_print(f"⚙️ 蒸发参数: {pressure} bar 💨, {temp}°C 🌡️, {final_time}s ⏰, {stir_speed} RPM 🌪️")
|
||||
debug_print(f"⏱️ 预计总时间: {(final_time + 20)/60:.1f} 分钟 ⌛")
|
||||
debug_print(f"📊 体积变化:")
|
||||
debug_print(f" - 蒸发前: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 蒸发后: {final_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 蒸发量: {evaporation_volume:.2f}mL ({evaporation_volume/max(original_liquid_volume, 0.01)*100:.1f}%)")
|
||||
debug_print("🎊" * 20)
|
||||
|
||||
|
||||
debug_print(f"蒸发协议生成完成: {len(action_sequence)} 个动作, 设备={rotavap_device}, 容器={target_vessel}")
|
||||
|
||||
return action_sequence
|
||||
|
||||
@@ -2,87 +2,64 @@ from typing import List, Dict, Any, Optional
|
||||
import networkx as nx
|
||||
import logging
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[FILTER] {message}")
|
||||
|
||||
def find_filter_device(G: nx.DiGraph) -> str:
|
||||
"""查找过滤器设备"""
|
||||
debug_print("🔍 查找过滤器设备... 🌊")
|
||||
|
||||
# 查找过滤器设备
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if 'filter' in node_class.lower() or 'filter' in node.lower():
|
||||
debug_print(f"🎉 找到过滤器设备: {node} ✨")
|
||||
debug_print(f"找到过滤器设备: {node}")
|
||||
return node
|
||||
|
||||
# 如果没找到,寻找可能的过滤器名称
|
||||
debug_print("🔎 在预定义名称中搜索过滤器... 📋")
|
||||
|
||||
possible_names = ["filter", "filter_1", "virtual_filter", "filtration_unit"]
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
debug_print(f"🎉 找到过滤器设备: {name} ✨")
|
||||
debug_print(f"找到过滤器设备: {name}")
|
||||
return name
|
||||
|
||||
debug_print("😭 未找到过滤器设备 💔")
|
||||
|
||||
raise ValueError("未找到过滤器设备")
|
||||
|
||||
def validate_vessel(G: nx.DiGraph, vessel: str, vessel_type: str = "容器") -> None:
|
||||
"""验证容器是否存在"""
|
||||
debug_print(f"🔍 验证{vessel_type}: '{vessel}' 🧪")
|
||||
|
||||
if not vessel:
|
||||
debug_print(f"❌ {vessel_type}不能为空! 😱")
|
||||
raise ValueError(f"{vessel_type}不能为空")
|
||||
|
||||
|
||||
if vessel not in G.nodes():
|
||||
debug_print(f"❌ {vessel_type} '{vessel}' 不存在于系统中! 😞")
|
||||
raise ValueError(f"{vessel_type} '{vessel}' 不存在于系统中")
|
||||
|
||||
debug_print(f"✅ {vessel_type} '{vessel}' 验证通过 🎯")
|
||||
|
||||
def generate_filter_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
filtrate_vessel: dict = {"id": "waste"},
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成过滤操作的协议序列 - 支持体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 过滤容器字典(必需)- 包含需要过滤的混合物
|
||||
filtrate_vessel: 滤液容器名称(可选)- 如果提供则收集滤液
|
||||
**kwargs: 其他参数(兼容性)
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 过滤操作的动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
filtrate_vessel_id, filtrate_vessel_data = get_vessel(filtrate_vessel)
|
||||
|
||||
debug_print("🌊" * 20)
|
||||
debug_print("🚀 开始生成过滤协议(支持体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 🧪 filtrate_vessel: {filtrate_vessel}")
|
||||
debug_print(f" ⚙️ 其他参数: {kwargs}")
|
||||
debug_print("🌊" * 20)
|
||||
|
||||
|
||||
debug_print(f"开始生成过滤协议: vessel={vessel_id}, filtrate_vessel={filtrate_vessel_id}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 新增:记录过滤前的容器状态
|
||||
debug_print("🔍 记录过滤前容器状态...")
|
||||
|
||||
# 记录过滤前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -90,79 +67,45 @@ def generate_filter_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
debug_print(f"📊 过滤前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# === 参数验证 ===
|
||||
debug_print("📍 步骤1: 参数验证... 🔧")
|
||||
|
||||
# 验证必需参数
|
||||
debug_print(" 🔍 验证必需参数...")
|
||||
validate_vessel(G, vessel_id, "过滤容器") # 🔧 使用 vessel_id
|
||||
debug_print(" ✅ 必需参数验证完成 🎯")
|
||||
|
||||
# 验证可选参数
|
||||
debug_print(" 🔍 验证可选参数...")
|
||||
validate_vessel(G, vessel_id, "过滤容器")
|
||||
|
||||
if filtrate_vessel:
|
||||
validate_vessel(G, filtrate_vessel_id, "滤液容器")
|
||||
debug_print(" 🌊 模式: 过滤并收集滤液 💧")
|
||||
else:
|
||||
debug_print(" 🧱 模式: 过滤并收集固体 🔬")
|
||||
debug_print(" ✅ 可选参数验证完成 🎯")
|
||||
|
||||
|
||||
# === 查找设备 ===
|
||||
debug_print("📍 步骤2: 查找设备... 🔍")
|
||||
|
||||
try:
|
||||
debug_print(" 🔎 搜索过滤器设备...")
|
||||
filter_device = find_filter_device(G)
|
||||
debug_print(f" 🎉 使用过滤器设备: {filter_device} 🌊✨")
|
||||
|
||||
debug_print(f"使用过滤器设备: {filter_device}")
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 设备查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"设备查找失败: {str(e)}")
|
||||
|
||||
# 🔧 新增:过滤效率和体积分配估算
|
||||
debug_print("📍 步骤2.5: 过滤体积分配估算... 📊")
|
||||
|
||||
# 估算过滤分离比例(基于经验数据)
|
||||
solid_ratio = 0.1 # 假设10%是固体(保留在过滤器上)
|
||||
liquid_ratio = 0.9 # 假设90%是液体(通过过滤器)
|
||||
volume_loss_ratio = 0.05 # 假设5%体积损失(残留在过滤器等)
|
||||
|
||||
# 从kwargs中获取过滤参数进行优化
|
||||
|
||||
# 过滤体积分配估算
|
||||
solid_ratio = 0.1
|
||||
liquid_ratio = 0.9
|
||||
volume_loss_ratio = 0.05
|
||||
|
||||
if "solid_content" in kwargs:
|
||||
try:
|
||||
solid_ratio = float(kwargs["solid_content"])
|
||||
liquid_ratio = 1.0 - solid_ratio
|
||||
debug_print(f"📋 使用指定的固体含量: {solid_ratio*100:.1f}%")
|
||||
except:
|
||||
debug_print("⚠️ 固体含量参数无效,使用默认值")
|
||||
|
||||
pass
|
||||
|
||||
if original_liquid_volume > 0:
|
||||
expected_filtrate_volume = original_liquid_volume * liquid_ratio * (1.0 - volume_loss_ratio)
|
||||
expected_solid_volume = original_liquid_volume * solid_ratio
|
||||
volume_loss = original_liquid_volume * volume_loss_ratio
|
||||
|
||||
debug_print(f"📊 过滤体积分配估算:")
|
||||
debug_print(f" - 原始体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 预计滤液体积: {expected_filtrate_volume:.2f}mL ({liquid_ratio*100:.1f}%)")
|
||||
debug_print(f" - 预计固体体积: {expected_solid_volume:.2f}mL ({solid_ratio*100:.1f}%)")
|
||||
debug_print(f" - 预计损失体积: {volume_loss:.2f}mL ({volume_loss_ratio*100:.1f}%)")
|
||||
|
||||
|
||||
# === 转移到过滤器(如果需要)===
|
||||
debug_print("📍 步骤3: 转移到过滤器... 🚚")
|
||||
|
||||
if vessel_id != filter_device: # 🔧 使用 vessel_id
|
||||
debug_print(f" 🚛 需要转移: {vessel_id} → {filter_device} 📦")
|
||||
|
||||
if vessel_id != filter_device:
|
||||
try:
|
||||
debug_print(" 🔄 开始执行转移操作...")
|
||||
# 使用pump protocol转移液体到过滤器
|
||||
transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel={"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
from_vessel={"id": vessel_id},
|
||||
to_vessel={"id": filter_device},
|
||||
volume=0.0, # 转移所有液体
|
||||
volume=0.0,
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
@@ -173,88 +116,59 @@ def generate_filter_protocol(
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=2.0
|
||||
)
|
||||
|
||||
|
||||
if transfer_actions:
|
||||
action_sequence.extend(transfer_actions)
|
||||
debug_print(f" ✅ 添加了 {len(transfer_actions)} 个转移动作 🚚✨")
|
||||
|
||||
# 🔧 新增:转移后更新容器体积
|
||||
debug_print(" 🔧 更新转移后的容器体积...")
|
||||
|
||||
# 原容器体积变为0(所有液体已转移)
|
||||
debug_print(f"添加了 {len(transfer_actions)} 个转移动作")
|
||||
|
||||
# 更新容器体积
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
vessel["data"]["liquid_volume"] = [0.0] if len(current_volume) > 0 else [0.0]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = 0.0
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = 0.0
|
||||
|
||||
debug_print(f" 📊 转移完成,{vessel_id} 体积更新为 0.0mL")
|
||||
|
||||
else:
|
||||
debug_print(" ⚠️ 转移协议返回空序列 🤔")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 转移失败: {str(e)} 😞")
|
||||
debug_print(" 🔄 继续执行,可能是直接连接的过滤器 🤞")
|
||||
else:
|
||||
debug_print(" ✅ 过滤容器就是过滤器,无需转移 🎯")
|
||||
|
||||
debug_print(f"转移失败: {str(e)},继续执行")
|
||||
|
||||
# === 执行过滤操作 ===
|
||||
debug_print("📍 步骤4: 执行过滤操作... 🌊")
|
||||
|
||||
# 构建过滤动作参数
|
||||
debug_print(" ⚙️ 构建过滤参数...")
|
||||
filter_kwargs = {
|
||||
"vessel": {"id": filter_device}, # 过滤器设备
|
||||
"filtrate_vessel": {"id": filtrate_vessel_id}, # 滤液容器(可能为空)
|
||||
"vessel": {"id": filter_device},
|
||||
"filtrate_vessel": {"id": filtrate_vessel_id},
|
||||
"stir": kwargs.get("stir", False),
|
||||
"stir_speed": kwargs.get("stir_speed", 0.0),
|
||||
"temp": kwargs.get("temp", 25.0),
|
||||
"continue_heatchill": kwargs.get("continue_heatchill", False),
|
||||
"volume": kwargs.get("volume", 0.0) # 0表示过滤所有
|
||||
"volume": kwargs.get("volume", 0.0)
|
||||
}
|
||||
|
||||
debug_print(f" 📋 过滤参数: {filter_kwargs}")
|
||||
debug_print(" 🌊 开始过滤操作...")
|
||||
|
||||
# 过滤动作
|
||||
|
||||
filter_action = {
|
||||
"device_id": filter_device,
|
||||
"action_name": "filter",
|
||||
"action_kwargs": filter_kwargs
|
||||
}
|
||||
action_sequence.append(filter_action)
|
||||
debug_print(" ✅ 过滤动作已添加 🌊✨")
|
||||
|
||||
|
||||
# 过滤后等待
|
||||
debug_print(" ⏳ 添加过滤后等待...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10.0}
|
||||
})
|
||||
debug_print(" ✅ 过滤后等待动作已添加 ⏰✨")
|
||||
|
||||
|
||||
# === 收集滤液(如果需要)===
|
||||
debug_print("📍 步骤5: 收集滤液... 💧")
|
||||
|
||||
if filtrate_vessel_id and filtrate_vessel_id not in G.neighbors(filter_device):
|
||||
debug_print(f" 🧪 收集滤液: {filter_device} → {filtrate_vessel_id} 💧")
|
||||
|
||||
try:
|
||||
debug_print(" 🔄 开始执行收集操作...")
|
||||
# 使用pump protocol收集滤液
|
||||
collect_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=filter_device,
|
||||
to_vessel=filtrate_vessel,
|
||||
volume=0.0, # 收集所有滤液
|
||||
volume=0.0,
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
@@ -265,19 +179,15 @@ def generate_filter_protocol(
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=2.0
|
||||
)
|
||||
|
||||
|
||||
if collect_actions:
|
||||
action_sequence.extend(collect_actions)
|
||||
debug_print(f" ✅ 添加了 {len(collect_actions)} 个收集动作 🧪✨")
|
||||
|
||||
# 🔧 新增:收集滤液后的体积更新
|
||||
debug_print(" 🔧 更新滤液容器体积...")
|
||||
|
||||
# 更新filtrate_vessel在图中的体积(如果它是节点)
|
||||
|
||||
# 更新滤液容器体积
|
||||
if filtrate_vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[filtrate_vessel_id]:
|
||||
G.nodes[filtrate_vessel_id]['data'] = {}
|
||||
|
||||
|
||||
current_filtrate_volume = G.nodes[filtrate_vessel_id]['data'].get('liquid_volume', 0.0)
|
||||
if isinstance(current_filtrate_volume, list):
|
||||
if len(current_filtrate_volume) > 0:
|
||||
@@ -286,58 +196,37 @@ def generate_filter_protocol(
|
||||
G.nodes[filtrate_vessel_id]['data']['liquid_volume'] = [expected_filtrate_volume]
|
||||
else:
|
||||
G.nodes[filtrate_vessel_id]['data']['liquid_volume'] = current_filtrate_volume + expected_filtrate_volume
|
||||
|
||||
debug_print(f" 📊 滤液容器 {filtrate_vessel_id} 体积增加 {expected_filtrate_volume:.2f}mL")
|
||||
|
||||
else:
|
||||
debug_print(" ⚠️ 收集协议返回空序列 🤔")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 收集滤液失败: {str(e)} 😞")
|
||||
debug_print(" 🔄 继续执行,可能滤液直接流入指定容器 🤞")
|
||||
else:
|
||||
debug_print(" 🧱 未指定滤液容器,固体保留在过滤器中 🔬")
|
||||
|
||||
# 🔧 新增:过滤完成后的容器状态更新
|
||||
debug_print("📍 步骤5.5: 过滤完成后状态更新... 📊")
|
||||
|
||||
debug_print(f"收集滤液失败: {str(e)},继续执行")
|
||||
|
||||
# 过滤完成后容器状态更新
|
||||
if vessel_id == filter_device:
|
||||
# 如果过滤容器就是过滤器,需要更新其体积状态
|
||||
if original_liquid_volume > 0:
|
||||
if filtrate_vessel:
|
||||
# 收集滤液模式:过滤器中主要保留固体
|
||||
remaining_volume = expected_solid_volume
|
||||
debug_print(f" 🧱 过滤器中保留固体: {remaining_volume:.2f}mL")
|
||||
else:
|
||||
# 保留固体模式:过滤器中保留所有物质
|
||||
remaining_volume = original_liquid_volume * (1.0 - volume_loss_ratio)
|
||||
debug_print(f" 🔬 过滤器中保留所有物质: {remaining_volume:.2f}mL")
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
vessel["data"]["liquid_volume"] = [remaining_volume] if len(current_volume) > 0 else [remaining_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = remaining_volume
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = remaining_volume
|
||||
|
||||
debug_print(f" 📊 过滤器 {vessel_id} 体积更新为: {remaining_volume:.2f}mL")
|
||||
|
||||
|
||||
# === 最终等待 ===
|
||||
debug_print("📍 步骤6: 最终等待... ⏰")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5.0}
|
||||
})
|
||||
debug_print(" ✅ 最终等待动作已添加 ⏰✨")
|
||||
|
||||
# 🔧 新增:过滤完成后的状态报告
|
||||
|
||||
# 最终状态
|
||||
final_vessel_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -345,22 +234,7 @@ def generate_filter_protocol(
|
||||
final_vessel_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
final_vessel_volume = current_volume
|
||||
|
||||
# === 总结 ===
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"🎉 过滤协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个 📝")
|
||||
debug_print(f"🥽 过滤容器: {vessel_id} 🧪")
|
||||
debug_print(f"🌊 过滤器设备: {filter_device} 🔧")
|
||||
debug_print(f"💧 滤液容器: {filtrate_vessel_id or '无(保留固体)'} 🧱")
|
||||
debug_print(f"⏱️ 预计总时间: {(len(action_sequence) * 5):.0f} 秒 ⌛")
|
||||
if original_liquid_volume > 0:
|
||||
debug_print(f"📊 体积变化统计:")
|
||||
debug_print(f" - 过滤前体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 过滤后容器体积: {final_vessel_volume:.2f}mL")
|
||||
if filtrate_vessel:
|
||||
debug_print(f" - 预计滤液体积: {expected_filtrate_volume:.2f}mL")
|
||||
debug_print(f" - 预计损失体积: {volume_loss:.2f}mL")
|
||||
debug_print("🎊" * 20)
|
||||
|
||||
|
||||
debug_print(f"过滤协议生成完成: {len(action_sequence)} 个动作, 容器={vessel_id}, 过滤器={filter_device}")
|
||||
|
||||
return action_sequence
|
||||
|
||||
@@ -1,118 +1,24 @@
|
||||
from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
import re
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.unit_parser import parse_time_input
|
||||
from .utils.vessel_parser import get_vessel, find_connected_heatchill
|
||||
from .utils.unit_parser import parse_time_input, parse_temperature_input
|
||||
from .utils.logger_util import debug_print
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[HEATCHILL] {message}")
|
||||
|
||||
|
||||
def parse_temp_input(temp_input: Union[str, float], default_temp: float = 25.0) -> float:
|
||||
"""
|
||||
解析温度输入(统一函数)
|
||||
|
||||
Args:
|
||||
temp_input: 温度输入
|
||||
default_temp: 默认温度
|
||||
|
||||
Returns:
|
||||
float: 温度(°C)
|
||||
"""
|
||||
if not temp_input:
|
||||
return default_temp
|
||||
|
||||
# 🔢 数值输入
|
||||
if isinstance(temp_input, (int, float)):
|
||||
result = float(temp_input)
|
||||
debug_print(f"🌡️ 数值温度: {temp_input} → {result}°C")
|
||||
return result
|
||||
|
||||
# 📝 字符串输入
|
||||
temp_str = str(temp_input).lower().strip()
|
||||
debug_print(f"🔍 解析温度: '{temp_str}'")
|
||||
|
||||
# 🎯 特殊温度
|
||||
special_temps = {
|
||||
"room temperature": 25.0, "reflux": 78.0, "ice bath": 0.0,
|
||||
"boiling": 100.0, "hot": 60.0, "warm": 40.0, "cold": 10.0
|
||||
}
|
||||
|
||||
if temp_str in special_temps:
|
||||
result = special_temps[temp_str]
|
||||
debug_print(f"🎯 特殊温度: '{temp_str}' → {result}°C")
|
||||
return result
|
||||
|
||||
# 📐 正则解析(如 "256 °C")
|
||||
temp_pattern = r'(\d+(?:\.\d+)?)\s*°?[cf]?'
|
||||
match = re.search(temp_pattern, temp_str)
|
||||
|
||||
if match:
|
||||
result = float(match.group(1))
|
||||
debug_print(f"✅ 温度解析: '{temp_str}' → {result}°C")
|
||||
return result
|
||||
|
||||
debug_print(f"⚠️ 无法解析温度: '{temp_str}',使用默认值: {default_temp}°C")
|
||||
return default_temp
|
||||
|
||||
def find_connected_heatchill(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与指定容器相连的加热/冷却设备"""
|
||||
debug_print(f"🔍 查找加热设备,目标容器: {vessel}")
|
||||
|
||||
# 🔧 查找所有加热设备
|
||||
heatchill_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if 'heatchill' in node_class.lower() or 'virtual_heatchill' in node_class:
|
||||
heatchill_nodes.append(node)
|
||||
debug_print(f"🎉 找到加热设备: {node}")
|
||||
|
||||
# 🔗 检查连接
|
||||
if vessel and heatchill_nodes:
|
||||
for heatchill in heatchill_nodes:
|
||||
if G.has_edge(heatchill, vessel) or G.has_edge(vessel, heatchill):
|
||||
debug_print(f"✅ 加热设备 '{heatchill}' 与容器 '{vessel}' 相连")
|
||||
return heatchill
|
||||
|
||||
# 🎯 使用第一个可用设备
|
||||
if heatchill_nodes:
|
||||
selected = heatchill_nodes[0]
|
||||
debug_print(f"🔧 使用第一个加热设备: {selected}")
|
||||
return selected
|
||||
|
||||
# 🆘 默认设备
|
||||
debug_print("⚠️ 未找到加热设备,使用默认设备")
|
||||
return "heatchill_1"
|
||||
|
||||
def validate_and_fix_params(temp: float, time: float, stir_speed: float) -> tuple:
|
||||
"""验证和修正参数"""
|
||||
# 🌡️ 温度范围验证
|
||||
if temp < -50.0 or temp > 300.0:
|
||||
debug_print(f"⚠️ 温度 {temp}°C 超出范围,修正为 25°C")
|
||||
temp = 25.0
|
||||
else:
|
||||
debug_print(f"✅ 温度 {temp}°C 在正常范围内")
|
||||
|
||||
# ⏰ 时间验证
|
||||
|
||||
if time < 0:
|
||||
debug_print(f"⚠️ 时间 {time}s 无效,修正为 300s")
|
||||
time = 300.0
|
||||
else:
|
||||
debug_print(f"✅ 时间 {time}s ({time/60:.1f}分钟) 有效")
|
||||
|
||||
# 🌪️ 搅拌速度验证
|
||||
|
||||
if stir_speed < 0 or stir_speed > 1500.0:
|
||||
debug_print(f"⚠️ 搅拌速度 {stir_speed} RPM 超出范围,修正为 300 RPM")
|
||||
stir_speed = 300.0
|
||||
else:
|
||||
debug_print(f"✅ 搅拌速度 {stir_speed} RPM 在正常范围内")
|
||||
|
||||
|
||||
return temp, time, stir_speed
|
||||
|
||||
def generate_heat_chill_protocol(
|
||||
@@ -131,7 +37,7 @@ def generate_heat_chill_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成加热/冷却操作的协议序列 - 支持vessel字典
|
||||
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 容器字典(从XDL传入)
|
||||
@@ -145,82 +51,58 @@ def generate_heat_chill_protocol(
|
||||
stir_speed: 搅拌速度 (RPM)
|
||||
purpose: 操作目的说明
|
||||
**kwargs: 其他参数(兼容性)
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 加热/冷却操作的动作序列
|
||||
"""
|
||||
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
debug_print("🌡️" * 20)
|
||||
debug_print("🚀 开始生成加热冷却协议(支持vessel字典)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 🌡️ temp: {temp}°C")
|
||||
debug_print(f" ⏰ time: {time}")
|
||||
debug_print(f" 🎯 temp_spec: {temp_spec}")
|
||||
debug_print(f" ⏱️ time_spec: {time_spec}")
|
||||
debug_print(f" 🌪️ stir: {stir} ({stir_speed} RPM)")
|
||||
debug_print(f" 🎭 purpose: '{purpose}'")
|
||||
debug_print("🌡️" * 20)
|
||||
|
||||
# 📋 参数验证
|
||||
debug_print("📍 步骤1: 参数验证... 🔧")
|
||||
if not vessel_id: # 🔧 使用 vessel_id
|
||||
debug_print("❌ vessel 参数不能为空! 😱")
|
||||
|
||||
debug_print(f"开始生成加热冷却协议: vessel={vessel_id}, temp={temp}°C, "
|
||||
f"time={time}, stir={stir} ({stir_speed} RPM), purpose='{purpose}'")
|
||||
|
||||
# 参数验证
|
||||
if not vessel_id:
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print(f"❌ 容器 '{vessel_id}' 不存在于系统中! 😞")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print("✅ 基础参数验证通过 🎯")
|
||||
|
||||
# 🔄 参数解析
|
||||
debug_print("📍 步骤2: 参数解析... ⚡")
|
||||
|
||||
#温度解析:优先使用 temp_spec
|
||||
final_temp = parse_temp_input(temp_spec, temp) if temp_spec else temp
|
||||
|
||||
|
||||
# 参数解析
|
||||
# 温度解析:优先使用 temp_spec
|
||||
final_temp = parse_temperature_input(temp_spec, temp) if temp_spec else temp
|
||||
|
||||
# 时间解析:优先使用 time_spec
|
||||
final_time = parse_time_input(time_spec) if time_spec else parse_time_input(time)
|
||||
|
||||
|
||||
# 参数修正
|
||||
final_temp, final_time, stir_speed = validate_and_fix_params(final_temp, final_time, stir_speed)
|
||||
|
||||
debug_print(f"🎯 最终参数: temp={final_temp}°C, time={final_time}s, stir_speed={stir_speed} RPM")
|
||||
|
||||
# 🔍 查找设备
|
||||
debug_print("📍 步骤3: 查找加热设备... 🔍")
|
||||
|
||||
debug_print(f"最终参数: temp={final_temp}°C, time={final_time}s, stir_speed={stir_speed} RPM")
|
||||
|
||||
# 查找设备
|
||||
try:
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
|
||||
debug_print(f"🎉 使用加热设备: {heatchill_id} ✨")
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
debug_print(f"使用加热设备: {heatchill_id}")
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 设备查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"无法找到加热设备: {str(e)}")
|
||||
|
||||
# 🚀 生成动作
|
||||
debug_print("📍 步骤4: 生成加热动作... 🔥")
|
||||
|
||||
# 🕐 模拟运行时间优化
|
||||
debug_print(" ⏱️ 检查模拟运行时间限制...")
|
||||
|
||||
# 生成动作
|
||||
# 模拟运行时间优化
|
||||
original_time = final_time
|
||||
simulation_time_limit = 100.0 # 模拟运行时间限制:100秒
|
||||
|
||||
|
||||
if final_time > simulation_time_limit:
|
||||
final_time = simulation_time_limit
|
||||
debug_print(f" 🎮 模拟运行优化: {original_time}s → {final_time}s (限制为{simulation_time_limit}s) ⚡")
|
||||
debug_print(f" 📊 时间缩短: {original_time/60:.1f}分钟 → {final_time/60:.1f}分钟 🚀")
|
||||
else:
|
||||
debug_print(f" ✅ 时间在限制内: {final_time}s ({final_time/60:.1f}分钟) 保持不变 🎯")
|
||||
|
||||
debug_print(f"模拟运行优化: {original_time}s → {final_time}s (限制为{simulation_time_limit}s)")
|
||||
|
||||
action_sequence = []
|
||||
heatchill_action = {
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel},
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": float(final_temp),
|
||||
"time": float(final_time),
|
||||
"stir": bool(stir),
|
||||
@@ -229,21 +111,10 @@ def generate_heat_chill_protocol(
|
||||
}
|
||||
}
|
||||
action_sequence.append(heatchill_action)
|
||||
debug_print("✅ 加热动作已添加 🔥✨")
|
||||
|
||||
# 显示时间调整信息
|
||||
if original_time != final_time:
|
||||
debug_print(f" 🎭 模拟优化说明: 原计划 {original_time/60:.1f}分钟,实际模拟 {final_time/60:.1f}分钟 ⚡")
|
||||
|
||||
# 🎊 总结
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"🎉 加热冷却协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f"🥽 加热容器: {vessel_id}")
|
||||
debug_print(f"🌡️ 目标温度: {final_temp}°C")
|
||||
debug_print(f"⏰ 加热时间: {final_time}s ({final_time/60:.1f}分钟)")
|
||||
debug_print("🎊" * 20)
|
||||
|
||||
|
||||
debug_print(f"加热冷却协议生成完成: {len(action_sequence)} 个动作, "
|
||||
f"vessel={vessel_id}, temp={final_temp}°C, time={final_time}s")
|
||||
|
||||
return action_sequence
|
||||
|
||||
def generate_heat_chill_to_temp_protocol(
|
||||
@@ -255,7 +126,7 @@ def generate_heat_chill_to_temp_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成加热到指定温度的协议(简化版)"""
|
||||
vessel_id, _ = get_vessel(vessel)
|
||||
debug_print(f"🌡️ 生成加热到温度协议: {vessel_id} → {temp}°C")
|
||||
debug_print(f"生成加热到温度协议: {vessel_id} → {temp}°C")
|
||||
return generate_heat_chill_protocol(G, vessel, temp, time, **kwargs)
|
||||
|
||||
def generate_heat_chill_start_protocol(
|
||||
@@ -266,21 +137,19 @@ def generate_heat_chill_start_protocol(
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成开始加热操作的协议序列"""
|
||||
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, _ = get_vessel(vessel)
|
||||
|
||||
debug_print("🔥 开始生成启动加热协议 ✨")
|
||||
debug_print(f"🥽 vessel: {vessel} (ID: {vessel_id}), 🌡️ temp: {temp}°C")
|
||||
|
||||
|
||||
debug_print(f"生成启动加热协议: vessel={vessel_id}, temp={temp}°C")
|
||||
|
||||
# 基础验证
|
||||
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print("❌ 容器验证失败!")
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
# 查找设备
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
|
||||
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
|
||||
# 生成动作
|
||||
action_sequence = [{
|
||||
"device_id": heatchill_id,
|
||||
@@ -291,8 +160,8 @@ def generate_heat_chill_start_protocol(
|
||||
"vessel": {"id": vessel_id},
|
||||
}
|
||||
}]
|
||||
|
||||
debug_print(f"✅ 启动加热协议生成完成 🎯")
|
||||
|
||||
debug_print(f"启动加热协议生成完成")
|
||||
return action_sequence
|
||||
|
||||
def generate_heat_chill_stop_protocol(
|
||||
@@ -301,21 +170,19 @@ def generate_heat_chill_stop_protocol(
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成停止加热操作的协议序列"""
|
||||
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, _ = get_vessel(vessel)
|
||||
|
||||
debug_print("🛑 开始生成停止加热协议 ✨")
|
||||
debug_print(f"🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
|
||||
|
||||
debug_print(f"生成停止加热协议: vessel={vessel_id}")
|
||||
|
||||
# 基础验证
|
||||
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print("❌ 容器验证失败!")
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
# 查找设备
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
|
||||
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
|
||||
# 生成动作
|
||||
action_sequence = [{
|
||||
"device_id": heatchill_id,
|
||||
@@ -323,6 +190,6 @@ def generate_heat_chill_stop_protocol(
|
||||
"action_kwargs": {
|
||||
}
|
||||
}]
|
||||
|
||||
debug_print(f"✅ 停止加热协议生成完成 🎯")
|
||||
|
||||
debug_print(f"停止加热协议生成完成")
|
||||
return action_sequence
|
||||
|
||||
@@ -1,105 +1,50 @@
|
||||
import networkx as nx
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .utils.vessel_parser import get_vessel
|
||||
|
||||
|
||||
def parse_temperature(temp_str: str) -> float:
|
||||
"""
|
||||
解析温度字符串,支持多种格式
|
||||
|
||||
Args:
|
||||
temp_str: 温度字符串(如 "45 °C", "45°C", "45")
|
||||
|
||||
Returns:
|
||||
float: 温度值(摄氏度)
|
||||
"""
|
||||
try:
|
||||
# 移除常见的温度单位和符号
|
||||
temp_clean = temp_str.replace("°C", "").replace("°", "").replace("C", "").strip()
|
||||
return float(temp_clean)
|
||||
except ValueError:
|
||||
print(f"HYDROGENATE: 无法解析温度 '{temp_str}',使用默认温度 25°C")
|
||||
return 25.0
|
||||
|
||||
|
||||
def parse_time(time_str: str) -> float:
|
||||
"""
|
||||
解析时间字符串,支持多种格式
|
||||
|
||||
Args:
|
||||
time_str: 时间字符串(如 "2 h", "120 min", "7200 s")
|
||||
|
||||
Returns:
|
||||
float: 时间值(秒)
|
||||
"""
|
||||
try:
|
||||
time_clean = time_str.lower().strip()
|
||||
|
||||
# 处理小时
|
||||
if "h" in time_clean:
|
||||
hours = float(time_clean.replace("h", "").strip())
|
||||
return hours * 3600.0
|
||||
|
||||
# 处理分钟
|
||||
if "min" in time_clean:
|
||||
minutes = float(time_clean.replace("min", "").strip())
|
||||
return minutes * 60.0
|
||||
|
||||
# 处理秒
|
||||
if "s" in time_clean:
|
||||
seconds = float(time_clean.replace("s", "").strip())
|
||||
return seconds
|
||||
|
||||
# 默认按小时处理
|
||||
return float(time_clean) * 3600.0
|
||||
|
||||
except ValueError:
|
||||
print(f"HYDROGENATE: 无法解析时间 '{time_str}',使用默认时间 2小时")
|
||||
return 7200.0 # 2小时
|
||||
from .utils.logger_util import debug_print
|
||||
from .utils.unit_parser import parse_temperature_input, parse_time_input
|
||||
|
||||
|
||||
def find_associated_solenoid_valve(G: nx.DiGraph, device_id: str) -> Optional[str]:
|
||||
"""查找与指定设备相关联的电磁阀"""
|
||||
solenoid_valves = [
|
||||
node for node in G.nodes()
|
||||
node for node in G.nodes()
|
||||
if ('solenoid' in (G.nodes[node].get('class') or '').lower()
|
||||
or 'solenoid_valve' in node)
|
||||
]
|
||||
|
||||
|
||||
# 通过网络连接查找直接相连的电磁阀
|
||||
for solenoid in solenoid_valves:
|
||||
if G.has_edge(device_id, solenoid) or G.has_edge(solenoid, device_id):
|
||||
return solenoid
|
||||
|
||||
|
||||
# 通过命名规则查找关联的电磁阀
|
||||
device_type = ""
|
||||
if 'gas' in device_id.lower():
|
||||
device_type = "gas"
|
||||
elif 'h2' in device_id.lower() or 'hydrogen' in device_id.lower():
|
||||
device_type = "gas"
|
||||
|
||||
|
||||
if device_type:
|
||||
for solenoid in solenoid_valves:
|
||||
if device_type in solenoid.lower():
|
||||
return solenoid
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_connected_device(G: nx.DiGraph, vessel: str, device_type: str) -> str:
|
||||
"""
|
||||
查找与容器相连的指定类型设备
|
||||
|
||||
|
||||
Args:
|
||||
G: 网络图
|
||||
vessel: 容器名称
|
||||
device_type: 设备类型 ('heater', 'stirrer', 'gas_source')
|
||||
|
||||
|
||||
Returns:
|
||||
str: 设备ID,如果没有则返回None
|
||||
"""
|
||||
print(f"HYDROGENATE: 正在查找与容器 '{vessel}' 相连的 {device_type}...")
|
||||
|
||||
# 根据设备类型定义搜索关键词
|
||||
if device_type == 'heater':
|
||||
keywords = ['heater', 'heat', 'heatchill']
|
||||
@@ -112,40 +57,38 @@ def find_connected_device(G: nx.DiGraph, vessel: str, device_type: str) -> str:
|
||||
device_class = 'virtual_gas_source'
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# 查找设备节点
|
||||
device_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_name = node.lower()
|
||||
node_class = node_data.get('class', '').lower()
|
||||
|
||||
# 通过名称匹配
|
||||
|
||||
if any(keyword in node_name for keyword in keywords):
|
||||
device_nodes.append(node)
|
||||
# 通过类型匹配
|
||||
elif device_class in node_class:
|
||||
device_nodes.append(node)
|
||||
|
||||
print(f"HYDROGENATE: 找到的{device_type}节点: {device_nodes}")
|
||||
|
||||
|
||||
debug_print(f"找到的{device_type}节点: {device_nodes}")
|
||||
|
||||
# 检查是否有设备与目标容器相连
|
||||
for device in device_nodes:
|
||||
if G.has_edge(device, vessel) or G.has_edge(vessel, device):
|
||||
print(f"HYDROGENATE: 找到与容器 '{vessel}' 相连的{device_type}: {device}")
|
||||
debug_print(f"找到与容器 '{vessel}' 相连的{device_type}: {device}")
|
||||
return device
|
||||
|
||||
|
||||
# 如果没有直接连接,查找距离最近的设备
|
||||
for device in device_nodes:
|
||||
try:
|
||||
path = nx.shortest_path(G, source=device, target=vessel)
|
||||
if len(path) <= 3: # 最多2个中间节点
|
||||
print(f"HYDROGENATE: 找到距离较近的{device_type}: {device}")
|
||||
debug_print(f"找到距离较近的{device_type}: {device}")
|
||||
return device
|
||||
except nx.NetworkXNoPath:
|
||||
continue
|
||||
|
||||
print(f"HYDROGENATE: 未找到与容器 '{vessel}' 相连的{device_type}")
|
||||
|
||||
debug_print(f"未找到与容器 '{vessel}' 相连的{device_type}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -158,36 +101,31 @@ def generate_hydrogenate_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成氢化反应协议序列 - 支持vessel字典
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为容器和设备
|
||||
vessel: 反应容器字典(从XDL传入)
|
||||
temp: 反应温度(如 "45 °C")
|
||||
time: 反应时间(如 "2 h")
|
||||
**kwargs: 其他可选参数,但不使用
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# 解析参数
|
||||
temperature = parse_temperature(temp)
|
||||
reaction_time = parse_time(time)
|
||||
|
||||
print("🧪" * 20)
|
||||
print(f"HYDROGENATE: 开始生成氢化反应协议(支持vessel字典)✨")
|
||||
print(f"📝 输入参数:")
|
||||
print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
print(f" 🌡️ 反应温度: {temperature}°C")
|
||||
print(f" ⏰ 反应时间: {reaction_time/3600:.1f} 小时")
|
||||
print("🧪" * 20)
|
||||
|
||||
# 🔧 新增:记录氢化前的容器状态(可选,氢化反应通常不改变体积)
|
||||
temperature = parse_temperature_input(temp)
|
||||
reaction_time = parse_time_input(time)
|
||||
|
||||
debug_print(f"开始生成氢化反应协议: vessel={vessel_id}, "
|
||||
f"temp={temperature}°C, time={reaction_time/3600:.1f}h")
|
||||
|
||||
# 记录氢化前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -195,47 +133,36 @@ def generate_hydrogenate_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
print(f"📊 氢化前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
print("📍 步骤1: 验证目标容器...")
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
print(f"⚠️ HYDROGENATE: 警告 - 容器 '{vessel_id}' 不存在于系统中,跳过氢化反应")
|
||||
if vessel_id not in G.nodes():
|
||||
debug_print(f"⚠️ 容器 '{vessel_id}' 不存在于系统中,跳过氢化反应")
|
||||
return action_sequence
|
||||
print(f"✅ 容器 '{vessel_id}' 验证通过")
|
||||
|
||||
|
||||
# 2. 查找相连的设备
|
||||
print("📍 步骤2: 查找相连设备...")
|
||||
heater_id = find_connected_device(G, vessel_id, 'heater') # 🔧 使用 vessel_id
|
||||
stirrer_id = find_connected_device(G, vessel_id, 'stirrer') # 🔧 使用 vessel_id
|
||||
gas_source_id = find_connected_device(G, vessel_id, 'gas_source') # 🔧 使用 vessel_id
|
||||
|
||||
print(f"🔧 设备配置:")
|
||||
print(f" 🔥 加热器: {heater_id or '未找到'}")
|
||||
print(f" 🌪️ 搅拌器: {stirrer_id or '未找到'}")
|
||||
print(f" 💨 气源: {gas_source_id or '未找到'}")
|
||||
|
||||
heater_id = find_connected_device(G, vessel_id, 'heater')
|
||||
stirrer_id = find_connected_device(G, vessel_id, 'stirrer')
|
||||
gas_source_id = find_connected_device(G, vessel_id, 'gas_source')
|
||||
|
||||
debug_print(f"设备配置: heater={heater_id or '未找到'}, "
|
||||
f"stirrer={stirrer_id or '未找到'}, gas={gas_source_id or '未找到'}")
|
||||
|
||||
# 3. 启动搅拌器
|
||||
print("📍 步骤3: 启动搅拌器...")
|
||||
if stirrer_id:
|
||||
print(f"🌪️ 启动搅拌器 {stirrer_id}")
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_speed": 300.0,
|
||||
"purpose": "氢化反应: 开始搅拌"
|
||||
}
|
||||
})
|
||||
print("✅ 搅拌器启动动作已添加")
|
||||
else:
|
||||
print(f"⚠️ HYDROGENATE: 警告 - 未找到搅拌器,继续执行")
|
||||
|
||||
debug_print(f"⚠️ 未找到搅拌器,继续执行")
|
||||
|
||||
# 4. 启动气源(氢气)
|
||||
print("📍 步骤4: 启动氢气源...")
|
||||
if gas_source_id:
|
||||
print(f"💨 启动气源 {gas_source_id} (氢气)")
|
||||
action_sequence.append({
|
||||
"device_id": gas_source_id,
|
||||
"action_name": "set_status",
|
||||
@@ -243,11 +170,10 @@ def generate_hydrogenate_protocol(
|
||||
"string": "ON"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# 查找相关的电磁阀
|
||||
gas_solenoid = find_associated_solenoid_valve(G, gas_source_id)
|
||||
if gas_solenoid:
|
||||
print(f"🚪 开启气源电磁阀 {gas_solenoid}")
|
||||
action_sequence.append({
|
||||
"device_id": gas_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
@@ -255,12 +181,10 @@ def generate_hydrogenate_protocol(
|
||||
"command": "OPEN"
|
||||
}
|
||||
})
|
||||
print("✅ 氢气源启动动作已添加")
|
||||
else:
|
||||
print(f"⚠️ HYDROGENATE: 警告 - 未找到气源,继续执行")
|
||||
|
||||
debug_print(f"⚠️ 未找到气源,继续执行")
|
||||
|
||||
# 5. 等待气体稳定
|
||||
print("📍 步骤5: 等待气体环境稳定...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -268,22 +192,19 @@ def generate_hydrogenate_protocol(
|
||||
"description": "等待氢气环境稳定"
|
||||
}
|
||||
})
|
||||
print("✅ 气体稳定等待动作已添加")
|
||||
|
||||
|
||||
# 6. 启动加热器
|
||||
print("📍 步骤6: 启动加热反应...")
|
||||
if heater_id:
|
||||
print(f"🔥 启动加热器 {heater_id} 到 {temperature}°C")
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": temperature,
|
||||
"purpose": f"氢化反应: 加热到 {temperature}°C"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# 等待温度稳定
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
@@ -292,52 +213,38 @@ def generate_hydrogenate_protocol(
|
||||
"description": f"等待温度稳定到 {temperature}°C"
|
||||
}
|
||||
})
|
||||
|
||||
# 🕐 模拟运行时间优化
|
||||
print(" ⏰ 检查模拟运行时间限制...")
|
||||
|
||||
# 模拟运行时间优化
|
||||
original_reaction_time = reaction_time
|
||||
simulation_time_limit = 60.0 # 模拟运行时间限制:60秒
|
||||
|
||||
simulation_time_limit = 60.0
|
||||
|
||||
if reaction_time > simulation_time_limit:
|
||||
reaction_time = simulation_time_limit
|
||||
print(f" 🎮 模拟运行优化: {original_reaction_time}s → {reaction_time}s (限制为{simulation_time_limit}s)")
|
||||
print(f" 📊 时间缩短: {original_reaction_time/3600:.2f}小时 → {reaction_time/60:.1f}分钟")
|
||||
else:
|
||||
print(f" ✅ 时间在限制内: {reaction_time}s ({reaction_time/60:.1f}分钟) 保持不变")
|
||||
|
||||
debug_print(f"模拟运行优化: {original_reaction_time}s → {reaction_time}s")
|
||||
|
||||
# 保持反应温度
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": temperature,
|
||||
"time": reaction_time,
|
||||
"purpose": f"氢化反应: 保持 {temperature}°C,反应 {reaction_time/60:.1f}分钟" + (f" (模拟时间)" if original_reaction_time != reaction_time else "")
|
||||
}
|
||||
})
|
||||
|
||||
# 显示时间调整信息
|
||||
if original_reaction_time != reaction_time:
|
||||
print(f" 🎭 模拟优化说明: 原计划 {original_reaction_time/3600:.2f}小时,实际模拟 {reaction_time/60:.1f}分钟")
|
||||
|
||||
print("✅ 加热反应动作已添加")
|
||||
|
||||
|
||||
else:
|
||||
print(f"⚠️ HYDROGENATE: 警告 - 未找到加热器,使用室温反应")
|
||||
|
||||
# 🕐 室温反应也需要时间优化
|
||||
print(" ⏰ 检查室温反应模拟时间限制...")
|
||||
debug_print(f"⚠️ 未找到加热器,使用室温反应")
|
||||
|
||||
# 室温反应也需要时间优化
|
||||
original_reaction_time = reaction_time
|
||||
simulation_time_limit = 60.0 # 模拟运行时间限制:60秒
|
||||
|
||||
simulation_time_limit = 60.0
|
||||
|
||||
if reaction_time > simulation_time_limit:
|
||||
reaction_time = simulation_time_limit
|
||||
print(f" 🎮 室温反应时间优化: {original_reaction_time}s → {reaction_time}s")
|
||||
print(f" 📊 时间缩短: {original_reaction_time/3600:.2f}小时 → {reaction_time/60:.1f}分钟")
|
||||
else:
|
||||
print(f" ✅ 室温反应时间在限制内: {reaction_time}s 保持不变")
|
||||
|
||||
debug_print(f"室温反应时间优化: {original_reaction_time}s → {reaction_time}s")
|
||||
|
||||
# 室温反应,只等待时间
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
@@ -346,28 +253,19 @@ def generate_hydrogenate_protocol(
|
||||
"description": f"室温氢化反应 {reaction_time/60:.1f}分钟" + (f" (模拟时间)" if original_reaction_time != reaction_time else "")
|
||||
}
|
||||
})
|
||||
|
||||
# 显示时间调整信息
|
||||
if original_reaction_time != reaction_time:
|
||||
print(f" 🎭 室温反应优化说明: 原计划 {original_reaction_time/3600:.2f}小时,实际模拟 {reaction_time/60:.1f}分钟")
|
||||
|
||||
print("✅ 室温反应等待动作已添加")
|
||||
|
||||
|
||||
# 7. 停止加热
|
||||
print("📍 步骤7: 停止加热...")
|
||||
if heater_id:
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"purpose": "氢化反应完成,停止加热"
|
||||
}
|
||||
})
|
||||
print("✅ 停止加热动作已添加")
|
||||
|
||||
|
||||
# 8. 等待冷却
|
||||
print("📍 步骤8: 等待冷却...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -375,15 +273,12 @@ def generate_hydrogenate_protocol(
|
||||
"description": "等待反应混合物冷却"
|
||||
}
|
||||
})
|
||||
print("✅ 冷却等待动作已添加")
|
||||
|
||||
|
||||
# 9. 停止气源
|
||||
print("📍 步骤9: 停止氢气源...")
|
||||
if gas_source_id:
|
||||
# 先关闭电磁阀
|
||||
gas_solenoid = find_associated_solenoid_valve(G, gas_source_id)
|
||||
if gas_solenoid:
|
||||
print(f"🚪 关闭气源电磁阀 {gas_solenoid}")
|
||||
action_sequence.append({
|
||||
"device_id": gas_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
@@ -391,7 +286,7 @@ def generate_hydrogenate_protocol(
|
||||
"command": "CLOSED"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# 再关闭气源
|
||||
action_sequence.append({
|
||||
"device_id": gas_source_id,
|
||||
@@ -400,59 +295,24 @@ def generate_hydrogenate_protocol(
|
||||
"string": "OFF"
|
||||
}
|
||||
})
|
||||
print("✅ 氢气源停止动作已添加")
|
||||
|
||||
|
||||
# 10. 停止搅拌
|
||||
print("📍 步骤10: 停止搅拌...")
|
||||
if stirrer_id:
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"purpose": "氢化反应完成,停止搅拌"
|
||||
}
|
||||
})
|
||||
print("✅ 停止搅拌动作已添加")
|
||||
|
||||
# 🔧 新增:氢化完成后的状态(氢化反应通常不改变体积)
|
||||
final_liquid_volume = original_liquid_volume # 氢化反应体积基本不变
|
||||
|
||||
|
||||
# 氢化完成后的状态(氢化反应通常不改变体积)
|
||||
final_liquid_volume = original_liquid_volume
|
||||
|
||||
# 总结
|
||||
print("🎊" * 20)
|
||||
print(f"🎉 氢化反应协议生成完成! ✨")
|
||||
print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
print(f"🥽 反应容器: {vessel_id}")
|
||||
print(f"🌡️ 反应温度: {temperature}°C")
|
||||
print(f"⏰ 反应时间: {reaction_time/60:.1f}分钟")
|
||||
print(f"⏱️ 预计总时间: {(reaction_time + 450)/3600:.1f} 小时")
|
||||
print(f"📊 体积状态:")
|
||||
print(f" - 反应前体积: {original_liquid_volume:.2f}mL")
|
||||
print(f" - 反应后体积: {final_liquid_volume:.2f}mL (氢化反应体积基本不变)")
|
||||
print("🎊" * 20)
|
||||
|
||||
debug_print(f"氢化反应协议生成完成: {len(action_sequence)} 个动作, "
|
||||
f"vessel={vessel_id}, temp={temperature}°C, time={reaction_time/60:.1f}min, "
|
||||
f"volume={original_liquid_volume:.2f}→{final_liquid_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
# 测试函数
|
||||
def test_hydrogenate_protocol():
|
||||
"""测试氢化反应协议"""
|
||||
print("🧪 === HYDROGENATE PROTOCOL 测试 === ✨")
|
||||
|
||||
# 测试温度解析
|
||||
test_temps = ["45 °C", "45°C", "45", "25 C", "invalid"]
|
||||
for temp in test_temps:
|
||||
parsed = parse_temperature(temp)
|
||||
print(f"温度 '{temp}' -> {parsed}°C")
|
||||
|
||||
# 测试时间解析
|
||||
test_times = ["2 h", "120 min", "7200 s", "2", "invalid"]
|
||||
for time in test_times:
|
||||
parsed = parse_time(time)
|
||||
print(f"时间 '{time}' -> {parsed/3600:.1f} 小时")
|
||||
|
||||
print("✅ 测试完成 🎉")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_hydrogenate_protocol()
|
||||
@@ -2,99 +2,18 @@ import traceback
|
||||
import numpy as np
|
||||
import networkx as nx
|
||||
import asyncio
|
||||
import time as time_module # 🔧 重命名time模块
|
||||
import time as time_module # 重命名time模块
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import debug_print
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.resource_helper import get_resource_liquid_volume
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""强制输出调试信息"""
|
||||
output = f"[TRANSFER] {message}"
|
||||
logger.info(output)
|
||||
|
||||
|
||||
def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
||||
"""
|
||||
从容器节点的数据中获取液体体积
|
||||
"""
|
||||
debug_print(f"🔍 开始读取容器 '{vessel}' 的液体体积...")
|
||||
|
||||
if vessel not in G.nodes():
|
||||
logger.error(f"❌ 容器 '{vessel}' 不存在于系统图中")
|
||||
debug_print(f" - 系统中的容器: {list(G.nodes())}")
|
||||
return 0.0
|
||||
|
||||
vessel_data = G.nodes[vessel].get('data', {})
|
||||
debug_print(f"📋 容器 '{vessel}' 的数据结构: {vessel_data}")
|
||||
|
||||
total_volume = 0.0
|
||||
|
||||
# 方法1:检查 'liquid' 字段(列表格式)
|
||||
debug_print("🔍 方法1: 检查 'liquid' 字段...")
|
||||
if 'liquid' in vessel_data:
|
||||
liquids = vessel_data['liquid']
|
||||
debug_print(f" - liquid 字段类型: {type(liquids)}")
|
||||
debug_print(f" - liquid 字段内容: {liquids}")
|
||||
|
||||
if isinstance(liquids, list):
|
||||
debug_print(f" - liquid 是列表,包含 {len(liquids)} 个元素")
|
||||
for i, liquid in enumerate(liquids):
|
||||
debug_print(f" 液体 {i + 1}: {liquid}")
|
||||
if isinstance(liquid, dict):
|
||||
volume_keys = ['liquid_volume', 'volume', 'amount', 'quantity']
|
||||
for key in volume_keys:
|
||||
if key in liquid:
|
||||
try:
|
||||
vol = float(liquid[key])
|
||||
total_volume += vol
|
||||
debug_print(f" ✅ 从 '{key}' 读取体积: {vol}mL")
|
||||
break
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f" ⚠️ 无法转换 '{key}': {liquid[key]} -> {str(e)}")
|
||||
continue
|
||||
else:
|
||||
debug_print(f" - liquid 不是列表: {type(liquids)}")
|
||||
else:
|
||||
debug_print(" - 没有 'liquid' 字段")
|
||||
|
||||
# 方法2:检查直接的体积字段
|
||||
debug_print("🔍 方法2: 检查直接体积字段...")
|
||||
volume_keys = ['total_volume', 'volume', 'liquid_volume', 'amount', 'current_volume']
|
||||
for key in volume_keys:
|
||||
if key in vessel_data:
|
||||
try:
|
||||
vol = float(vessel_data[key])
|
||||
total_volume = max(total_volume, vol) # 取最大值
|
||||
debug_print(f" ✅ 从容器数据 '{key}' 读取体积: {vol}mL")
|
||||
break
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f" ⚠️ 无法转换 '{key}': {vessel_data[key]} -> {str(e)}")
|
||||
continue
|
||||
|
||||
# 方法3:检查 'state' 或 'status' 字段
|
||||
debug_print("🔍 方法3: 检查 'state' 字段...")
|
||||
if 'state' in vessel_data and isinstance(vessel_data['state'], dict):
|
||||
state = vessel_data['state']
|
||||
debug_print(f" - state 字段内容: {state}")
|
||||
if 'volume' in state:
|
||||
try:
|
||||
vol = float(state['volume'])
|
||||
total_volume = max(total_volume, vol)
|
||||
debug_print(f" ✅ 从容器状态读取体积: {vol}mL")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f" ⚠️ 无法转换 state.volume: {state['volume']} -> {str(e)}")
|
||||
else:
|
||||
debug_print(" - 没有 'state' 字段或不是字典")
|
||||
|
||||
debug_print(f"📊 容器 '{vessel}' 最终检测体积: {total_volume}mL")
|
||||
return total_volume
|
||||
|
||||
|
||||
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||
"""
|
||||
判断是否为泵阀一体设备
|
||||
@@ -122,108 +41,77 @@ def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||
|
||||
def find_connected_pump(G, valve_node):
|
||||
"""
|
||||
查找与阀门相连的泵节点 - 修复版本
|
||||
🔧 修复:区分电磁阀和多通阀,电磁阀不参与泵查找
|
||||
查找与阀门相连的泵节点
|
||||
区分电磁阀和多通阀,电磁阀不参与泵查找
|
||||
"""
|
||||
debug_print(f"🔍 查找与阀门 {valve_node} 相连的泵...")
|
||||
|
||||
# 🔧 关键修复:检查节点类型,电磁阀不应该查找泵
|
||||
# 检查节点类型,电磁阀不应该查找泵
|
||||
node_data = G.nodes.get(valve_node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
|
||||
debug_print(f" - 阀门类型: {node_class}")
|
||||
|
||||
# 如果是电磁阀,不应该查找泵(电磁阀只是开关)
|
||||
if ("solenoid" in node_class.lower() or "solenoid_valve" in valve_node.lower()):
|
||||
debug_print(f" ⚠️ {valve_node} 是电磁阀,不应该查找泵节点")
|
||||
raise ValueError(f"电磁阀 {valve_node} 不应该参与泵查找逻辑")
|
||||
|
||||
# 只有多通阀等复杂阀门才需要查找连接的泵
|
||||
if ("multiway" in node_class.lower() or "valve" in node_class.lower()):
|
||||
debug_print(f" - {valve_node} 是多通阀,查找连接的泵...")
|
||||
# 方法1:直接相邻的泵
|
||||
for neighbor in G.neighbors(valve_node):
|
||||
neighbor_class = G.nodes[neighbor].get("class", "") or ""
|
||||
# 排除非 电磁阀 和 泵 的邻居
|
||||
debug_print(f" - 检查邻居 {neighbor}, class: {neighbor_class}")
|
||||
if "pump" in neighbor_class.lower():
|
||||
debug_print(f" ✅ 找到直接相连的泵: {neighbor}")
|
||||
return neighbor
|
||||
|
||||
# 方法2:通过路径查找泵(最多2跳)
|
||||
debug_print(f" - 未找到直接相连的泵,尝试路径查找...")
|
||||
pump_nodes = [
|
||||
node_id for node_id in G.nodes()
|
||||
if "pump" in (G.nodes[node_id].get("class", "") or "").lower()
|
||||
]
|
||||
|
||||
# 获取所有泵节点
|
||||
pump_nodes = []
|
||||
for node_id in G.nodes():
|
||||
node_class = G.nodes[node_id].get("class", "") or ""
|
||||
if "pump" in node_class.lower():
|
||||
pump_nodes.append(node_id)
|
||||
|
||||
debug_print(f" - 系统中的泵节点: {pump_nodes}")
|
||||
|
||||
# 查找到泵的最短路径
|
||||
for pump_node in pump_nodes:
|
||||
try:
|
||||
if nx.has_path(G, valve_node, pump_node):
|
||||
path = nx.shortest_path(G, valve_node, pump_node)
|
||||
path_length = len(path) - 1
|
||||
debug_print(f" - 到泵 {pump_node} 的路径: {path}, 距离: {path_length}")
|
||||
|
||||
if path_length <= 2: # 最多允许2跳
|
||||
debug_print(f" ✅ 通过路径找到泵: {pump_node}")
|
||||
if len(path) - 1 <= 2: # 最多允许2跳
|
||||
return pump_node
|
||||
except nx.NetworkXNoPath:
|
||||
continue
|
||||
|
||||
# 最终失败
|
||||
debug_print(f" ❌ 完全找不到泵节点")
|
||||
raise ValueError(f"未找到与阀 {valve_node} 相连的泵节点")
|
||||
|
||||
|
||||
def build_pump_valve_maps(G, pump_backbone):
|
||||
"""
|
||||
构建泵-阀门映射 - 修复版本
|
||||
🔧 修复:过滤掉电磁阀,只处理需要泵的多通阀
|
||||
构建泵-阀门映射
|
||||
过滤掉电磁阀,只处理需要泵的多通阀
|
||||
"""
|
||||
pumps_from_node = {}
|
||||
valve_from_node = {}
|
||||
|
||||
debug_print(f"🔧 构建泵-阀门映射,原始骨架: {pump_backbone}")
|
||||
|
||||
# 🔧 关键修复:过滤掉电磁阀
|
||||
# 过滤掉电磁阀
|
||||
filtered_backbone = []
|
||||
for node in pump_backbone:
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
|
||||
# 跳过电磁阀
|
||||
if ("solenoid" in node_class.lower() or "solenoid_valve" in node.lower()):
|
||||
debug_print(f" - 跳过电磁阀: {node}")
|
||||
continue
|
||||
|
||||
filtered_backbone.append(node)
|
||||
|
||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||
|
||||
for node in filtered_backbone:
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
if is_integrated_pump(node_class, node):
|
||||
pumps_from_node[node] = node
|
||||
valve_from_node[node] = node
|
||||
debug_print(f" - 集成泵-阀: {node}")
|
||||
else:
|
||||
try:
|
||||
pump_node = find_connected_pump(G, node)
|
||||
pumps_from_node[node] = pump_node
|
||||
valve_from_node[node] = node
|
||||
debug_print(f" - 阀门 {node} -> 泵 {pump_node}")
|
||||
except ValueError as e:
|
||||
debug_print(f" - 跳过节点 {node}: {str(e)}")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
debug_print(f"🔧 最终映射: pumps={pumps_from_node}, valves={valve_from_node}")
|
||||
debug_print(f"泵-阀映射: pumps={pumps_from_node}, valves={valve_from_node}")
|
||||
return pumps_from_node, valve_from_node
|
||||
|
||||
|
||||
@@ -236,8 +124,8 @@ def generate_pump_protocol(
|
||||
transfer_flowrate: float = 0.5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成泵操作的动作序列 - 修复版本
|
||||
🔧 修复:正确处理包含电磁阀的路径
|
||||
生成泵操作的动作序列
|
||||
正确处理包含电磁阀的路径
|
||||
"""
|
||||
pump_action_sequence = []
|
||||
nodes = G.nodes(data=True)
|
||||
@@ -256,7 +144,6 @@ def generate_pump_protocol(
|
||||
logger.warning(f"transfer_flowrate <= 0,使用默认值 {transfer_flowrate}mL/s")
|
||||
|
||||
# 验证容器存在
|
||||
debug_print(f"🔍 验证源容器 '{from_vessel_id}' 和目标容器 '{to_vessel_id}' 是否存在...")
|
||||
if from_vessel_id not in G.nodes():
|
||||
logger.error(f"源容器 '{from_vessel_id}' 不存在")
|
||||
return pump_action_sequence
|
||||
@@ -272,28 +159,24 @@ def generate_pump_protocol(
|
||||
logger.error(f"无法找到从 '{from_vessel_id}' 到 '{to_vessel_id}' 的路径")
|
||||
return pump_action_sequence
|
||||
|
||||
# 🔧 关键修复:正确构建泵骨架,排除容器和电磁阀
|
||||
# 正确构建泵骨架,排除容器和电磁阀
|
||||
pump_backbone = []
|
||||
for node in shortest_path:
|
||||
# 跳过起始和结束容器
|
||||
if node == from_vessel_id or node == to_vessel_id:
|
||||
continue
|
||||
|
||||
# 跳过电磁阀(电磁阀不参与泵操作)
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
if ("solenoid" in node_class.lower() or "solenoid_valve" in node.lower()):
|
||||
debug_print(f"PUMP_TRANSFER: 跳过电磁阀 {node}")
|
||||
continue
|
||||
|
||||
# 只包含多通阀和泵
|
||||
if ("multiway" in node_class.lower() or "valve" in node_class.lower() or "pump" in node_class.lower()):
|
||||
pump_backbone.append(node)
|
||||
|
||||
debug_print(f"PUMP_TRANSFER: 过滤后的泵骨架: {pump_backbone}")
|
||||
debug_print(f"PUMP_TRANSFER: 泵骨架: {pump_backbone}")
|
||||
|
||||
if not pump_backbone:
|
||||
debug_print("PUMP_TRANSFER: 没有泵骨架节点,可能是直接容器连接或只有电磁阀")
|
||||
debug_print("PUMP_TRANSFER: 没有泵骨架节点")
|
||||
return pump_action_sequence
|
||||
|
||||
if transfer_flowrate == 0:
|
||||
@@ -309,7 +192,7 @@ def generate_pump_protocol(
|
||||
debug_print("PUMP_TRANSFER: 没有可用的泵映射")
|
||||
return pump_action_sequence
|
||||
|
||||
# 🔧 修复:安全地获取最小转移体积
|
||||
# 安全地获取最小转移体积
|
||||
try:
|
||||
min_transfer_volumes = []
|
||||
for node in pump_backbone:
|
||||
@@ -339,19 +222,19 @@ def generate_pump_protocol(
|
||||
volume_left = volume
|
||||
debug_print(f"PUMP_TRANSFER: 需要 {repeats} 次转移,单次最大体积 {min_transfer_volume} mL")
|
||||
|
||||
# 🆕 只在开头打印总体概览
|
||||
# 只在开头打印总体概览
|
||||
if repeats > 1:
|
||||
debug_print(f"🔄 分批转移概览: 总体积 {volume:.2f}mL,需要 {repeats} 次转移")
|
||||
logger.info(f"🔄 分批转移概览: 总体积 {volume:.2f}mL,需要 {repeats} 次转移")
|
||||
debug_print(f"分批转移: 总体积 {volume:.2f}mL, {repeats} 次, 单次最大 {min_transfer_volume} mL")
|
||||
logger.info(f"分批转移: 总体积 {volume:.2f}mL, {repeats} 次转移")
|
||||
|
||||
# 🔧 创建一个自定义的wait动作,用于在执行时打印日志
|
||||
# 创建一个自定义的wait动作,用于在执行时打印日志
|
||||
def create_progress_log_action(message: str) -> Dict[str, Any]:
|
||||
"""创建一个特殊的等待动作,在执行时打印进度日志"""
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1, # 很短的等待时间
|
||||
"progress_message": message # 自定义字段,用于进度日志
|
||||
"time": 0.1,
|
||||
"progress_message": message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,12 +242,12 @@ def generate_pump_protocol(
|
||||
for i in range(repeats):
|
||||
current_volume = min(volume_left, min_transfer_volume)
|
||||
|
||||
# 🆕 在每次循环开始时添加进度日志
|
||||
if repeats > 1:
|
||||
start_message = f"🚀 准备开始第 {i + 1}/{repeats} 次转移: {current_volume:.2f}mL ({from_vessel_id} → {to_vessel_id}) 🚰"
|
||||
pump_action_sequence.append(create_progress_log_action(start_message))
|
||||
pump_action_sequence.append(create_progress_log_action(
|
||||
f"第 {i + 1}/{repeats} 次转移: {current_volume:.2f}mL ({from_vessel_id} -> {to_vessel_id})"
|
||||
))
|
||||
|
||||
# 🔧 修复:安全地获取边数据
|
||||
# 安全地获取边数据
|
||||
def get_safe_edge_data(node_a, node_b, key):
|
||||
try:
|
||||
edge_data = G.get_edge_data(node_a, node_b)
|
||||
@@ -467,13 +350,13 @@ def generate_pump_protocol(
|
||||
])
|
||||
pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 3}})
|
||||
|
||||
# 🆕 在每次循环结束时添加完成日志
|
||||
# 在每次循环结束时添加完成日志
|
||||
if repeats > 1:
|
||||
remaining_volume = volume_left - current_volume
|
||||
if remaining_volume > 0:
|
||||
end_message = f"✅ 第 {i + 1}/{repeats} 次转移完成! 剩余 {remaining_volume:.2f}mL 待转移 ⏳"
|
||||
end_message = f"第 {i + 1}/{repeats} 次完成, 剩余 {remaining_volume:.2f}mL"
|
||||
else:
|
||||
end_message = f"🎉 第 {i + 1}/{repeats} 次转移完成! 全部 {volume:.2f}mL 转移完毕 ✨"
|
||||
end_message = f"第 {i + 1}/{repeats} 次完成, 全部 {volume:.2f}mL 转移完毕"
|
||||
|
||||
pump_action_sequence.append(create_progress_log_action(end_message))
|
||||
|
||||
@@ -515,300 +398,205 @@ def generate_pump_protocol_with_rinsing(
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
|
||||
with generate_pump_protocol_with_rinsing._lock:
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"PUMP_TRANSFER: 🚀 开始生成协议 (同步版本)")
|
||||
debug_print(f" 📍 路径: {from_vessel_id} -> {to_vessel_id}")
|
||||
debug_print(f" 🕐 时间戳: {time_module.time()}")
|
||||
debug_print(f" 🔒 获得执行锁")
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"PUMP_TRANSFER: {from_vessel_id} -> {to_vessel_id}, volume={volume}, flowrate={flowrate}")
|
||||
|
||||
# 短暂延迟,避免快速重复调用
|
||||
time_module.sleep(0.01)
|
||||
|
||||
debug_print("🔍 步骤1: 开始体积处理...")
|
||||
|
||||
# 1. 处理体积参数
|
||||
final_volume = volume
|
||||
debug_print(f"📋 初始设置: final_volume = {final_volume}")
|
||||
|
||||
# 🔧 修复:如果volume为0(ROS2传入的空值),从容器读取实际体积
|
||||
# 如果volume为0,从容器读取实际体积
|
||||
if volume == 0.0:
|
||||
debug_print("🎯 检测到 volume=0.0,开始自动体积检测...")
|
||||
|
||||
# 直接从源容器读取实际体积
|
||||
actual_volume = get_vessel_liquid_volume(G, from_vessel_id)
|
||||
debug_print(f"📖 从容器 '{from_vessel_id}' 读取到体积: {actual_volume}mL")
|
||||
actual_volume = get_resource_liquid_volume(G.nodes.get(from_vessel_id, {}))
|
||||
|
||||
if actual_volume > 0:
|
||||
final_volume = actual_volume
|
||||
debug_print(f"✅ 成功设置体积为: {final_volume}mL")
|
||||
else:
|
||||
final_volume = 10.0 # 如果读取失败,使用默认值
|
||||
logger.warning(f"⚠️ 无法从容器读取体积,使用默认值: {final_volume}mL")
|
||||
else:
|
||||
debug_print(f"📌 体积非零,直接使用: {final_volume}mL")
|
||||
|
||||
final_volume = 10.0
|
||||
logger.warning(f"无法从容器读取体积,使用默认值: {final_volume}mL")
|
||||
# 处理 amount 参数
|
||||
if amount and amount.strip():
|
||||
debug_print(f"🔍 检测到 amount 参数: '{amount}',开始解析...")
|
||||
parsed_volume = _parse_amount_to_volume(amount)
|
||||
debug_print(f"📖 从 amount 解析得到体积: {parsed_volume}mL")
|
||||
|
||||
if parsed_volume > 0:
|
||||
final_volume = parsed_volume
|
||||
debug_print(f"✅ 使用从 amount 解析的体积: {final_volume}mL")
|
||||
elif parsed_volume == 0.0 and amount.lower().strip() == "all":
|
||||
debug_print("🎯 检测到 amount='all',从容器读取全部体积...")
|
||||
actual_volume = get_vessel_liquid_volume(G, from_vessel_id)
|
||||
actual_volume = get_resource_liquid_volume(G.nodes.get(from_vessel_id, {}))
|
||||
if actual_volume > 0:
|
||||
final_volume = actual_volume
|
||||
debug_print(f"✅ amount='all',设置体积为: {final_volume}mL")
|
||||
|
||||
# 最终体积验证
|
||||
debug_print(f"🔍 步骤2: 最终体积验证...")
|
||||
if final_volume <= 0:
|
||||
logger.error(f"❌ 体积无效: {final_volume}mL")
|
||||
logger.error(f"体积无效: {final_volume}mL")
|
||||
final_volume = 10.0
|
||||
logger.warning(f"⚠️ 强制设置为默认值: {final_volume}mL")
|
||||
logger.warning(f"强制设置为默认值: {final_volume}mL")
|
||||
|
||||
debug_print(f"✅ 最终确定体积: {final_volume}mL")
|
||||
debug_print(f"最终体积: {final_volume}mL")
|
||||
|
||||
# 2. 处理流速参数
|
||||
debug_print(f"🔍 步骤3: 处理流速参数...")
|
||||
debug_print(f" - 原始 flowrate: {flowrate}")
|
||||
debug_print(f" - 原始 transfer_flowrate: {transfer_flowrate}")
|
||||
|
||||
final_flowrate = flowrate if flowrate > 0 else 2.5
|
||||
final_transfer_flowrate = transfer_flowrate if transfer_flowrate > 0 else 0.5
|
||||
|
||||
if flowrate <= 0:
|
||||
logger.warning(f"⚠️ flowrate <= 0,修正为: {final_flowrate}mL/s")
|
||||
logger.warning(f"flowrate <= 0,修正为: {final_flowrate}mL/s")
|
||||
if transfer_flowrate <= 0:
|
||||
logger.warning(f"⚠️ transfer_flowrate <= 0,修正为: {final_transfer_flowrate}mL/s")
|
||||
|
||||
debug_print(f"✅ 修正后流速: flowrate={final_flowrate}mL/s, transfer_flowrate={final_transfer_flowrate}mL/s")
|
||||
logger.warning(f"transfer_flowrate <= 0,修正为: {final_transfer_flowrate}mL/s")
|
||||
|
||||
# 3. 根据时间计算流速
|
||||
if time > 0 and final_volume > 0:
|
||||
debug_print(f"🔍 步骤4: 根据时间计算流速...")
|
||||
calculated_flowrate = final_volume / time
|
||||
debug_print(f" - 计算得到流速: {calculated_flowrate}mL/s")
|
||||
|
||||
if flowrate <= 0 or flowrate == 2.5:
|
||||
final_flowrate = min(calculated_flowrate, 10.0)
|
||||
debug_print(f" - 调整 flowrate 为: {final_flowrate}mL/s")
|
||||
if transfer_flowrate <= 0 or transfer_flowrate == 0.5:
|
||||
final_transfer_flowrate = min(calculated_flowrate, 5.0)
|
||||
debug_print(f" - 调整 transfer_flowrate 为: {final_transfer_flowrate}mL/s")
|
||||
|
||||
# 4. 根据速度规格调整
|
||||
if rate_spec:
|
||||
debug_print(f"🔍 步骤5: 根据速度规格调整...")
|
||||
debug_print(f" - 速度规格: '{rate_spec}'")
|
||||
|
||||
if rate_spec == "dropwise":
|
||||
final_flowrate = min(final_flowrate, 0.1)
|
||||
final_transfer_flowrate = min(final_transfer_flowrate, 0.1)
|
||||
debug_print(f" - dropwise模式,流速调整为: {final_flowrate}mL/s")
|
||||
elif rate_spec == "slowly":
|
||||
final_flowrate = min(final_flowrate, 0.5)
|
||||
final_transfer_flowrate = min(final_transfer_flowrate, 0.3)
|
||||
debug_print(f" - slowly模式,流速调整为: {final_flowrate}mL/s")
|
||||
elif rate_spec == "quickly":
|
||||
final_flowrate = max(final_flowrate, 5.0)
|
||||
final_transfer_flowrate = max(final_transfer_flowrate, 2.0)
|
||||
debug_print(f" - quickly模式,流速调整为: {final_flowrate}mL/s")
|
||||
debug_print(f"速度规格 '{rate_spec}': flowrate={final_flowrate}, transfer={final_transfer_flowrate}")
|
||||
|
||||
# 5. 处理冲洗参数
|
||||
debug_print(f"🔍 步骤6: 处理冲洗参数...")
|
||||
final_rinsing_solvent = rinsing_solvent
|
||||
final_rinsing_volume = rinsing_volume if rinsing_volume > 0 else 5.0
|
||||
final_rinsing_repeats = rinsing_repeats if rinsing_repeats > 0 else 2
|
||||
|
||||
if rinsing_volume <= 0:
|
||||
logger.warning(f"⚠️ rinsing_volume <= 0,修正为: {final_rinsing_volume}mL")
|
||||
logger.warning(f"rinsing_volume <= 0,修正为: {final_rinsing_volume}mL")
|
||||
if rinsing_repeats <= 0:
|
||||
logger.warning(f"⚠️ rinsing_repeats <= 0,修正为: {final_rinsing_repeats}次")
|
||||
logger.warning(f"rinsing_repeats <= 0,修正为: {final_rinsing_repeats}次")
|
||||
|
||||
# 根据物理属性调整冲洗参数
|
||||
if viscous or solid:
|
||||
final_rinsing_repeats = max(final_rinsing_repeats, 3)
|
||||
final_rinsing_volume = max(final_rinsing_volume, 10.0)
|
||||
debug_print(f"🧪 粘稠/固体物质,调整冲洗参数:{final_rinsing_repeats}次,{final_rinsing_volume}mL")
|
||||
|
||||
# 参数总结
|
||||
debug_print("📊 最终参数总结:")
|
||||
debug_print(f" - 体积: {final_volume}mL")
|
||||
debug_print(f" - 流速: {final_flowrate}mL/s")
|
||||
debug_print(f" - 转移流速: {final_transfer_flowrate}mL/s")
|
||||
debug_print(f" - 冲洗溶剂: '{final_rinsing_solvent}'")
|
||||
debug_print(f" - 冲洗体积: {final_rinsing_volume}mL")
|
||||
debug_print(f" - 冲洗次数: {final_rinsing_repeats}次")
|
||||
|
||||
# ========== 执行基础转移 ==========
|
||||
|
||||
debug_print("🔧 步骤7: 开始执行基础转移...")
|
||||
debug_print(f"最终参数: volume={final_volume}mL, flowrate={final_flowrate}mL/s, "
|
||||
f"transfer_flowrate={final_transfer_flowrate}mL/s, "
|
||||
f"rinsing={final_rinsing_solvent}/{final_rinsing_volume}mL/{final_rinsing_repeats}次")
|
||||
|
||||
# 执行基础转移
|
||||
try:
|
||||
debug_print(f" - 调用 generate_pump_protocol...")
|
||||
debug_print(
|
||||
f" - 参数: G, '{from_vessel_id}', '{to_vessel_id}', {final_volume}, {final_flowrate}, {final_transfer_flowrate}")
|
||||
|
||||
pump_action_sequence = generate_pump_protocol(
|
||||
G, from_vessel_id, to_vessel_id, final_volume,
|
||||
final_flowrate, final_transfer_flowrate
|
||||
)
|
||||
|
||||
debug_print(f" - generate_pump_protocol 返回结果:")
|
||||
debug_print(f" - 动作序列长度: {len(pump_action_sequence)}")
|
||||
debug_print(f" - 动作序列是否为空: {len(pump_action_sequence) == 0}")
|
||||
debug_print(f"基础转移生成了 {len(pump_action_sequence)} 个动作")
|
||||
|
||||
if not pump_action_sequence:
|
||||
debug_print("❌ 基础转移协议生成为空,可能是路径问题")
|
||||
debug_print(f" - 源容器存在: {from_vessel_id in G.nodes()}")
|
||||
debug_print(f" - 目标容器存在: {to_vessel_id in G.nodes()}")
|
||||
debug_print("基础转移协议为空")
|
||||
|
||||
if from_vessel_id in G.nodes() and to_vessel_id in G.nodes():
|
||||
try:
|
||||
path = nx.shortest_path(G, source=from_vessel_id, target=to_vessel_id)
|
||||
debug_print(f" - 路径存在: {path}")
|
||||
except Exception as path_error:
|
||||
debug_print(f" - 无法找到路径: {str(path_error)}")
|
||||
debug_print(f"路径存在: {path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return [
|
||||
{
|
||||
"device_id": "system",
|
||||
"action_name": "log_message",
|
||||
"action_kwargs": {
|
||||
"message": f"⚠️ 路径问题,无法转移: {final_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}"
|
||||
"message": f"路径问题,无法转移: {final_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
debug_print(f"✅ 基础转移生成了 {len(pump_action_sequence)} 个动作")
|
||||
|
||||
# 打印前几个动作用于调试
|
||||
if len(pump_action_sequence) > 0:
|
||||
debug_print("🔍 前几个动作预览:")
|
||||
for i, action in enumerate(pump_action_sequence[:3]):
|
||||
debug_print(f" 动作 {i + 1}: {action}")
|
||||
if len(pump_action_sequence) > 3:
|
||||
debug_print(f" ... 还有 {len(pump_action_sequence) - 3} 个动作")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 基础转移失败: {str(e)}")
|
||||
import traceback
|
||||
debug_print(f"详细错误: {traceback.format_exc()}")
|
||||
debug_print(f"基础转移失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return [
|
||||
{
|
||||
"device_id": "system",
|
||||
"action_name": "log_message",
|
||||
"action_kwargs": {
|
||||
"message": f"❌ 转移失败: {final_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}, 错误: {str(e)}"
|
||||
"message": f"转移失败: {final_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}, 错误: {str(e)}"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# ========== 执行冲洗操作 ==========
|
||||
|
||||
debug_print("🔧 步骤8: 检查冲洗操作...")
|
||||
|
||||
# 执行冲洗操作
|
||||
if final_rinsing_solvent and final_rinsing_solvent.strip() and final_rinsing_repeats > 0:
|
||||
debug_print(f"🧽 开始冲洗操作,溶剂: '{final_rinsing_solvent}'")
|
||||
|
||||
try:
|
||||
if final_rinsing_solvent.strip() != "air":
|
||||
debug_print(" - 执行液体冲洗...")
|
||||
rinsing_actions = _generate_rinsing_sequence(
|
||||
G, from_vessel_id, to_vessel_id, final_rinsing_solvent,
|
||||
final_rinsing_volume, final_rinsing_repeats,
|
||||
final_flowrate, final_transfer_flowrate
|
||||
)
|
||||
pump_action_sequence.extend(rinsing_actions)
|
||||
debug_print(f" - 添加了 {len(rinsing_actions)} 个冲洗动作")
|
||||
else:
|
||||
debug_print(" - 执行空气冲洗...")
|
||||
air_rinsing_actions = _generate_air_rinsing_sequence(
|
||||
G, from_vessel_id, to_vessel_id, final_rinsing_volume, final_rinsing_repeats,
|
||||
final_flowrate, final_transfer_flowrate
|
||||
)
|
||||
pump_action_sequence.extend(air_rinsing_actions)
|
||||
debug_print(f" - 添加了 {len(air_rinsing_actions)} 个空气冲洗动作")
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 冲洗操作失败: {str(e)},跳过冲洗")
|
||||
debug_print(f"冲洗操作失败: {str(e)}")
|
||||
else:
|
||||
debug_print(f"⏭️ 跳过冲洗操作")
|
||||
debug_print(f" - 溶剂: '{final_rinsing_solvent}'")
|
||||
debug_print(f" - 次数: {final_rinsing_repeats}")
|
||||
debug_print(f" - 条件满足: {bool(final_rinsing_solvent and final_rinsing_solvent.strip() and final_rinsing_repeats > 0)}")
|
||||
debug_print(f"跳过冲洗 (solvent='{final_rinsing_solvent}', repeats={final_rinsing_repeats})")
|
||||
|
||||
# ========== 最终结果 ==========
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 PUMP_TRANSFER: 协议生成完成")
|
||||
debug_print(f" 📊 总动作数: {len(pump_action_sequence)}")
|
||||
debug_print(f" 📋 最终体积: {final_volume}mL")
|
||||
debug_print(f" 🚀 执行路径: {from_vessel_id} -> {to_vessel_id}")
|
||||
# 最终结果
|
||||
debug_print(f"PUMP_TRANSFER 完成: {from_vessel_id} -> {to_vessel_id}, "
|
||||
f"volume={final_volume}mL, 动作数={len(pump_action_sequence)}")
|
||||
|
||||
# 最终验证
|
||||
if len(pump_action_sequence) == 0:
|
||||
debug_print("🚨 协议生成结果为空!这是异常情况")
|
||||
return [
|
||||
{
|
||||
"device_id": "system",
|
||||
"action_name": "log_message",
|
||||
"action_kwargs": {
|
||||
"message": f"🚨 协议生成失败: 无法生成任何动作序列"
|
||||
"message": "协议生成失败: 无法生成任何动作序列"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
debug_print("=" * 60)
|
||||
return pump_action_sequence
|
||||
|
||||
|
||||
def _parse_amount_to_volume(amount: str) -> float:
|
||||
"""解析 amount 字符串为体积"""
|
||||
debug_print(f"🔍 解析 amount: '{amount}'")
|
||||
|
||||
if not amount:
|
||||
debug_print(" - amount 为空,返回 0.0")
|
||||
return 0.0
|
||||
|
||||
amount = amount.lower().strip()
|
||||
debug_print(f" - 处理后的 amount: '{amount}'")
|
||||
|
||||
# 处理特殊关键词
|
||||
if amount == "all":
|
||||
debug_print(" - 检测到 'all',返回 0.0(需要后续处理)")
|
||||
return 0.0 # 返回0.0,让调用者处理
|
||||
|
||||
# 提取数字
|
||||
import re
|
||||
numbers = re.findall(r'[\d.]+', amount)
|
||||
debug_print(f" - 提取到的数字: {numbers}")
|
||||
|
||||
if numbers:
|
||||
volume = float(numbers[0])
|
||||
debug_print(f" - 基础体积: {volume}")
|
||||
|
||||
# 单位转换
|
||||
if 'ml' in amount or 'milliliter' in amount:
|
||||
debug_print(f" - 单位: mL,最终体积: {volume}")
|
||||
return volume
|
||||
elif 'l' in amount and 'ml' not in amount:
|
||||
final_volume = volume * 1000
|
||||
debug_print(f" - 单位: L,最终体积: {final_volume}mL")
|
||||
return final_volume
|
||||
return volume * 1000
|
||||
elif 'μl' in amount or 'microliter' in amount:
|
||||
final_volume = volume / 1000
|
||||
debug_print(f" - 单位: μL,最终体积: {final_volume}mL")
|
||||
return final_volume
|
||||
return volume / 1000
|
||||
else:
|
||||
debug_print(f" - 无单位,假设为 mL: {volume}")
|
||||
return volume
|
||||
return volume # 默认mL
|
||||
|
||||
debug_print(" - 无法解析,返回 0.0")
|
||||
return 0.0
|
||||
|
||||
|
||||
|
||||
@@ -4,76 +4,64 @@ import logging
|
||||
from typing import List, Dict, Any, Tuple, Union
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel
|
||||
from .utils.unit_parser import parse_volume_input
|
||||
from .utils.logger_util import debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[RECRYSTALLIZE] {message}")
|
||||
|
||||
|
||||
def parse_ratio(ratio_str: str) -> Tuple[float, float]:
|
||||
"""
|
||||
解析比例字符串,支持多种格式
|
||||
|
||||
|
||||
Args:
|
||||
ratio_str: 比例字符串(如 "1:1", "3:7", "50:50")
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[float, float]: 比例元组 (ratio1, ratio2)
|
||||
"""
|
||||
debug_print(f"⚖️ 开始解析比例: '{ratio_str}' 📊")
|
||||
|
||||
try:
|
||||
# 处理 "1:1", "3:7", "50:50" 等格式
|
||||
if ":" in ratio_str:
|
||||
parts = ratio_str.split(":")
|
||||
if len(parts) == 2:
|
||||
ratio1 = float(parts[0])
|
||||
ratio2 = float(parts[1])
|
||||
debug_print(f"✅ 冒号格式解析成功: {ratio1}:{ratio2} 🎯")
|
||||
return ratio1, ratio2
|
||||
|
||||
# 处理 "1-1", "3-7" 等格式
|
||||
|
||||
if "-" in ratio_str:
|
||||
parts = ratio_str.split("-")
|
||||
if len(parts) == 2:
|
||||
ratio1 = float(parts[0])
|
||||
ratio2 = float(parts[1])
|
||||
debug_print(f"✅ 横线格式解析成功: {ratio1}:{ratio2} 🎯")
|
||||
return ratio1, ratio2
|
||||
|
||||
# 处理 "1,1", "3,7" 等格式
|
||||
|
||||
if "," in ratio_str:
|
||||
parts = ratio_str.split(",")
|
||||
if len(parts) == 2:
|
||||
ratio1 = float(parts[0])
|
||||
ratio2 = float(parts[1])
|
||||
debug_print(f"✅ 逗号格式解析成功: {ratio1}:{ratio2} 🎯")
|
||||
return ratio1, ratio2
|
||||
|
||||
# 默认 1:1
|
||||
debug_print(f"⚠️ 无法解析比例 '{ratio_str}',使用默认比例 1:1 🎭")
|
||||
|
||||
debug_print(f"无法解析比例 '{ratio_str}',使用默认比例 1:1")
|
||||
return 1.0, 1.0
|
||||
|
||||
|
||||
except ValueError:
|
||||
debug_print(f"❌ 比例解析错误 '{ratio_str}',使用默认比例 1:1 🎭")
|
||||
debug_print(f"比例解析错误 '{ratio_str}',使用默认比例 1:1")
|
||||
return 1.0, 1.0
|
||||
|
||||
|
||||
def generate_recrystallize_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
ratio: str,
|
||||
solvent1: str,
|
||||
solvent2: str,
|
||||
volume: Union[str, float], # 支持字符串和数值
|
||||
volume: Union[str, float],
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成重结晶协议序列 - 支持vessel字典和体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为容器和设备
|
||||
vessel: 目标容器字典(从XDL传入)
|
||||
@@ -82,28 +70,18 @@ def generate_recrystallize_protocol(
|
||||
solvent2: 第二种溶剂名称
|
||||
volume: 总体积(支持 "100 mL", "50", "2.5 L" 等)
|
||||
**kwargs: 其他可选参数
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
action_sequence = []
|
||||
|
||||
debug_print("💎" * 20)
|
||||
debug_print("🚀 开始生成重结晶协议(支持vessel字典和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" ⚖️ 比例: {ratio}")
|
||||
debug_print(f" 🧪 溶剂1: {solvent1}")
|
||||
debug_print(f" 🧪 溶剂2: {solvent2}")
|
||||
debug_print(f" 💧 总体积: {volume} (类型: {type(volume)})")
|
||||
debug_print("💎" * 20)
|
||||
|
||||
# 🔧 新增:记录重结晶前的容器状态
|
||||
debug_print("🔍 记录重结晶前容器状态...")
|
||||
|
||||
debug_print(f"开始生成重结晶协议: vessel={vessel_id}, ratio={ratio}, solvent1={solvent1}, solvent2={solvent2}, volume={volume}")
|
||||
|
||||
# 记录重结晶前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -111,102 +89,73 @@ def generate_recrystallize_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
debug_print(f"📊 重结晶前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
debug_print("📍 步骤1: 验证目标容器... 🔧")
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print(f"❌ 目标容器 '{vessel_id}' 不存在于系统中! 😱")
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"目标容器 '{vessel_id}' 不存在于系统中")
|
||||
debug_print(f"✅ 目标容器 '{vessel_id}' 验证通过 🎯")
|
||||
|
||||
|
||||
# 2. 解析体积(支持单位)
|
||||
debug_print("📍 步骤2: 解析体积(支持单位)... 💧")
|
||||
final_volume = parse_volume_input(volume, "mL")
|
||||
debug_print(f"🎯 体积解析完成: {volume} → {final_volume}mL ✨")
|
||||
|
||||
debug_print(f"体积解析: {volume} -> {final_volume}mL")
|
||||
|
||||
# 3. 解析比例
|
||||
debug_print("📍 步骤3: 解析比例... ⚖️")
|
||||
ratio1, ratio2 = parse_ratio(ratio)
|
||||
total_ratio = ratio1 + ratio2
|
||||
debug_print(f"🎯 比例解析完成: {ratio1}:{ratio2} (总比例: {total_ratio}) ✨")
|
||||
|
||||
|
||||
# 4. 计算各溶剂体积
|
||||
debug_print("📍 步骤4: 计算各溶剂体积... 🧮")
|
||||
volume1 = final_volume * (ratio1 / total_ratio)
|
||||
volume2 = final_volume * (ratio2 / total_ratio)
|
||||
|
||||
debug_print(f"🧪 {solvent1} 体积: {volume1:.2f} mL ({ratio1}/{total_ratio} × {final_volume})")
|
||||
debug_print(f"🧪 {solvent2} 体积: {volume2:.2f} mL ({ratio2}/{total_ratio} × {final_volume})")
|
||||
debug_print(f"✅ 体积计算完成: 总计 {volume1 + volume2:.2f} mL 🎯")
|
||||
|
||||
|
||||
debug_print(f"溶剂体积: {solvent1}={volume1:.2f}mL, {solvent2}={volume2:.2f}mL")
|
||||
|
||||
# 5. 查找溶剂容器
|
||||
debug_print("📍 步骤5: 查找溶剂容器... 🔍")
|
||||
try:
|
||||
debug_print(f" 🔍 查找溶剂1容器...")
|
||||
solvent1_vessel = find_solvent_vessel(G, solvent1)
|
||||
debug_print(f" 🎉 找到溶剂1容器: {solvent1_vessel} ✨")
|
||||
except ValueError as e:
|
||||
debug_print(f" ❌ 溶剂1容器查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"无法找到溶剂1 '{solvent1}': {str(e)}")
|
||||
|
||||
|
||||
try:
|
||||
debug_print(f" 🔍 查找溶剂2容器...")
|
||||
solvent2_vessel = find_solvent_vessel(G, solvent2)
|
||||
debug_print(f" 🎉 找到溶剂2容器: {solvent2_vessel} ✨")
|
||||
except ValueError as e:
|
||||
debug_print(f" ❌ 溶剂2容器查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"无法找到溶剂2 '{solvent2}': {str(e)}")
|
||||
|
||||
|
||||
# 6. 验证路径存在
|
||||
debug_print("📍 步骤6: 验证传输路径... 🛤️")
|
||||
try:
|
||||
path1 = nx.shortest_path(G, source=solvent1_vessel, target=vessel_id) # 🔧 使用 vessel_id
|
||||
debug_print(f" 🛤️ 溶剂1路径: {' → '.join(path1)} ✅")
|
||||
path1 = nx.shortest_path(G, source=solvent1_vessel, target=vessel_id)
|
||||
except nx.NetworkXNoPath:
|
||||
debug_print(f" ❌ 溶剂1路径不可达: {solvent1_vessel} → {vessel_id} 😞")
|
||||
raise ValueError(f"从溶剂1容器 '{solvent1_vessel}' 到目标容器 '{vessel_id}' 没有可用路径")
|
||||
|
||||
|
||||
try:
|
||||
path2 = nx.shortest_path(G, source=solvent2_vessel, target=vessel_id) # 🔧 使用 vessel_id
|
||||
debug_print(f" 🛤️ 溶剂2路径: {' → '.join(path2)} ✅")
|
||||
path2 = nx.shortest_path(G, source=solvent2_vessel, target=vessel_id)
|
||||
except nx.NetworkXNoPath:
|
||||
debug_print(f" ❌ 溶剂2路径不可达: {solvent2_vessel} → {vessel_id} 😞")
|
||||
raise ValueError(f"从溶剂2容器 '{solvent2_vessel}' 到目标容器 '{vessel_id}' 没有可用路径")
|
||||
|
||||
|
||||
# 7. 添加第一种溶剂
|
||||
debug_print("📍 步骤7: 添加第一种溶剂... 🧪")
|
||||
debug_print(f" 🚰 开始添加溶剂1: {solvent1} ({volume1:.2f} mL)")
|
||||
|
||||
try:
|
||||
pump_actions1 = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent1_vessel,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
volume=volume1, # 使用解析后的体积
|
||||
to_vessel=vessel_id,
|
||||
volume=volume1,
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
rinsing_solvent="", # 重结晶不需要清洗
|
||||
rinsing_solvent="",
|
||||
rinsing_volume=0.0,
|
||||
rinsing_repeats=0,
|
||||
solid=False,
|
||||
flowrate=2.0, # 正常流速
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=0.5
|
||||
)
|
||||
|
||||
|
||||
action_sequence.extend(pump_actions1)
|
||||
debug_print(f" ✅ 溶剂1泵送动作已添加: {len(pump_actions1)} 个动作 🚰✨")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 溶剂1泵协议生成失败: {str(e)} 😭")
|
||||
raise ValueError(f"生成溶剂1泵协议时出错: {str(e)}")
|
||||
|
||||
# 🔧 新增:更新容器体积 - 添加溶剂1后
|
||||
debug_print(" 🔧 更新容器体积 - 添加溶剂1后...")
|
||||
|
||||
# 更新容器体积 - 添加溶剂1后
|
||||
new_volume_after_solvent1 = original_liquid_volume + volume1
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -216,15 +165,14 @@ def generate_recrystallize_protocol(
|
||||
vessel["data"]["liquid_volume"] = [new_volume_after_solvent1]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume_after_solvent1
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume_after_solvent1
|
||||
@@ -232,53 +180,42 @@ def generate_recrystallize_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume_after_solvent1]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume_after_solvent1
|
||||
|
||||
debug_print(f" 📊 体积更新: {original_liquid_volume:.2f}mL + {volume1:.2f}mL = {new_volume_after_solvent1:.2f}mL")
|
||||
|
||||
|
||||
# 8. 等待溶剂1稳定
|
||||
debug_print(" ⏳ 添加溶剂1稳定等待...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 5.0, # 缩短等待时间
|
||||
"time": 5.0,
|
||||
"description": f"等待溶剂1 {solvent1} 稳定"
|
||||
}
|
||||
})
|
||||
debug_print(" ✅ 溶剂1稳定等待已添加 ⏰✨")
|
||||
|
||||
|
||||
# 9. 添加第二种溶剂
|
||||
debug_print("📍 步骤8: 添加第二种溶剂... 🧪")
|
||||
debug_print(f" 🚰 开始添加溶剂2: {solvent2} ({volume2:.2f} mL)")
|
||||
|
||||
try:
|
||||
pump_actions2 = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent2_vessel,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
volume=volume2, # 使用解析后的体积
|
||||
to_vessel=vessel_id,
|
||||
volume=volume2,
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
rinsing_solvent="", # 重结晶不需要清洗
|
||||
rinsing_solvent="",
|
||||
rinsing_volume=0.0,
|
||||
rinsing_repeats=0,
|
||||
solid=False,
|
||||
flowrate=2.0, # 正常流速
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=0.5
|
||||
)
|
||||
|
||||
|
||||
action_sequence.extend(pump_actions2)
|
||||
debug_print(f" ✅ 溶剂2泵送动作已添加: {len(pump_actions2)} 个动作 🚰✨")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 溶剂2泵协议生成失败: {str(e)} 😭")
|
||||
raise ValueError(f"生成溶剂2泵协议时出错: {str(e)}")
|
||||
|
||||
# 🔧 新增:更新容器体积 - 添加溶剂2后
|
||||
debug_print(" 🔧 更新容器体积 - 添加溶剂2后...")
|
||||
|
||||
# 更新容器体积 - 添加溶剂2后
|
||||
final_liquid_volume = new_volume_after_solvent1 + volume2
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -288,15 +225,14 @@ def generate_recrystallize_protocol(
|
||||
vessel["data"]["liquid_volume"] = [final_liquid_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = final_liquid_volume
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = final_liquid_volume
|
||||
@@ -304,36 +240,25 @@ def generate_recrystallize_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [final_liquid_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = final_liquid_volume
|
||||
|
||||
debug_print(f" 📊 最终体积: {new_volume_after_solvent1:.2f}mL + {volume2:.2f}mL = {final_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 10. 等待溶剂2稳定
|
||||
debug_print(" ⏳ 添加溶剂2稳定等待...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 5.0, # 缩短等待时间
|
||||
"time": 5.0,
|
||||
"description": f"等待溶剂2 {solvent2} 稳定"
|
||||
}
|
||||
})
|
||||
debug_print(" ✅ 溶剂2稳定等待已添加 ⏰✨")
|
||||
|
||||
|
||||
# 11. 等待重结晶完成
|
||||
debug_print("📍 步骤9: 等待重结晶完成... 💎")
|
||||
|
||||
# 模拟运行时间优化
|
||||
debug_print(" ⏱️ 检查模拟运行时间限制...")
|
||||
original_crystallize_time = 600.0 # 原始重结晶时间
|
||||
simulation_time_limit = 60.0 # 模拟运行时间限制:60秒
|
||||
original_crystallize_time = 600.0
|
||||
simulation_time_limit = 60.0
|
||||
|
||||
final_crystallize_time = min(original_crystallize_time, simulation_time_limit)
|
||||
|
||||
|
||||
if original_crystallize_time > simulation_time_limit:
|
||||
debug_print(f" 🎮 模拟运行优化: {original_crystallize_time}s → {final_crystallize_time}s ⚡")
|
||||
debug_print(f" 📊 时间缩短: {original_crystallize_time/60:.1f}分钟 → {final_crystallize_time/60:.1f}分钟 🚀")
|
||||
else:
|
||||
debug_print(f" ✅ 时间在限制内: {final_crystallize_time}s 保持不变 🎯")
|
||||
|
||||
debug_print(f"模拟运行优化: {original_crystallize_time}s -> {final_crystallize_time}s")
|
||||
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -341,50 +266,28 @@ def generate_recrystallize_protocol(
|
||||
"description": f"等待重结晶完成({solvent1}:{solvent2} = {ratio},总体积 {final_volume}mL)" + (f" (模拟时间)" if original_crystallize_time != final_crystallize_time else "")
|
||||
}
|
||||
})
|
||||
debug_print(f" ✅ 重结晶等待已添加: {final_crystallize_time}s 💎✨")
|
||||
|
||||
# 显示时间调整信息
|
||||
if original_crystallize_time != final_crystallize_time:
|
||||
debug_print(f" 🎭 模拟优化说明: 原计划 {original_crystallize_time/60:.1f}分钟,实际模拟 {final_crystallize_time/60:.1f}分钟 ⚡")
|
||||
|
||||
# 总结
|
||||
debug_print("💎" * 20)
|
||||
debug_print(f"🎉 重结晶协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f"🥽 目标容器: {vessel_id}")
|
||||
debug_print(f"💧 总体积变化:")
|
||||
debug_print(f" - 原始体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 添加溶剂: {final_volume:.2f}mL")
|
||||
debug_print(f" - 最终体积: {final_liquid_volume:.2f}mL")
|
||||
debug_print(f"⚖️ 溶剂比例: {solvent1}:{solvent2} = {ratio1}:{ratio2}")
|
||||
debug_print(f"🧪 溶剂1: {solvent1} ({volume1:.2f}mL)")
|
||||
debug_print(f"🧪 溶剂2: {solvent2} ({volume2:.2f}mL)")
|
||||
debug_print(f"⏱️ 预计总时间: {(final_crystallize_time + 10)/60:.1f} 分钟 ⌛")
|
||||
debug_print("💎" * 20)
|
||||
|
||||
|
||||
debug_print(f"重结晶协议生成完成: {len(action_sequence)} 个动作, 容器={vessel_id}, 体积变化: {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
# 测试函数
|
||||
def test_recrystallize_protocol():
|
||||
"""测试重结晶协议"""
|
||||
debug_print("🧪 === RECRYSTALLIZE PROTOCOL 测试 === ✨")
|
||||
|
||||
# 测试体积解析
|
||||
debug_print("💧 测试体积解析...")
|
||||
debug_print("=== RECRYSTALLIZE PROTOCOL 测试 ===")
|
||||
|
||||
test_volumes = ["100 mL", "2.5 L", "500", "50.5", "?", "invalid"]
|
||||
for vol in test_volumes:
|
||||
parsed = parse_volume_input(vol)
|
||||
debug_print(f" 📊 体积 '{vol}' -> {parsed}mL")
|
||||
|
||||
# 测试比例解析
|
||||
debug_print("⚖️ 测试比例解析...")
|
||||
debug_print(f"体积 '{vol}' -> {parsed}mL")
|
||||
|
||||
test_ratios = ["1:1", "3:7", "50:50", "1-1", "2,8", "invalid"]
|
||||
for ratio in test_ratios:
|
||||
r1, r2 = parse_ratio(ratio)
|
||||
debug_print(f" 📊 比例 '{ratio}' -> {r1}:{r2}")
|
||||
|
||||
debug_print("✅ 测试完成 🎉")
|
||||
debug_print(f"比例 '{ratio}' -> {r1}:{r2}")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_recrystallize_protocol()
|
||||
test_recrystallize_protocol()
|
||||
|
||||
@@ -1,253 +1,87 @@
|
||||
import networkx as nx
|
||||
import logging
|
||||
import sys
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .utils.logger_util import debug_print, action_log
|
||||
from .utils.vessel_parser import find_solvent_vessel
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
# 设置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 确保输出编码为UTF-8
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出函数 - 支持中文"""
|
||||
try:
|
||||
# 确保消息是字符串格式
|
||||
safe_message = str(message)
|
||||
print(f"[重置处理] {safe_message}", flush=True)
|
||||
logger.info(f"[重置处理] {safe_message}")
|
||||
except UnicodeEncodeError:
|
||||
# 如果编码失败,尝试替换不支持的字符
|
||||
safe_message = str(message).encode('utf-8', errors='replace').decode('utf-8')
|
||||
print(f"[重置处理] {safe_message}", flush=True)
|
||||
logger.info(f"[重置处理] {safe_message}")
|
||||
except Exception as e:
|
||||
# 最后的安全措施
|
||||
fallback_message = f"日志输出错误: {repr(message)}"
|
||||
print(f"[重置处理] {fallback_message}", flush=True)
|
||||
logger.info(f"[重置处理] {fallback_message}")
|
||||
|
||||
def create_action_log(message: str, emoji: str = "📝") -> Dict[str, Any]:
|
||||
"""创建一个动作日志 - 支持中文和emoji"""
|
||||
try:
|
||||
full_message = f"{emoji} {message}"
|
||||
debug_print(full_message)
|
||||
logger.info(full_message)
|
||||
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": full_message,
|
||||
"progress_message": full_message
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
# 如果emoji有问题,使用纯文本
|
||||
safe_message = f"[日志] {message}"
|
||||
debug_print(safe_message)
|
||||
logger.info(safe_message)
|
||||
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": safe_message,
|
||||
"progress_message": safe_message
|
||||
}
|
||||
}
|
||||
|
||||
def find_solvent_vessel(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""
|
||||
查找溶剂容器,支持多种匹配模式
|
||||
|
||||
Args:
|
||||
G: 网络图
|
||||
solvent: 溶剂名称(如 "methanol", "ethanol", "water")
|
||||
|
||||
Returns:
|
||||
str: 溶剂容器ID
|
||||
"""
|
||||
debug_print(f"🔍 正在查找溶剂 '{solvent}' 的容器...")
|
||||
|
||||
# 构建可能的容器名称
|
||||
possible_names = [
|
||||
f"flask_{solvent}", # flask_methanol
|
||||
f"bottle_{solvent}", # bottle_methanol
|
||||
f"reagent_{solvent}", # reagent_methanol
|
||||
f"reagent_bottle_{solvent}", # reagent_bottle_methanol
|
||||
f"{solvent}_flask", # methanol_flask
|
||||
f"{solvent}_bottle", # methanol_bottle
|
||||
f"{solvent}", # methanol
|
||||
f"vessel_{solvent}", # vessel_methanol
|
||||
]
|
||||
|
||||
debug_print(f"🎯 候选容器名称: {possible_names[:3]}... (共{len(possible_names)}个)")
|
||||
|
||||
# 第一步:通过容器名称匹配
|
||||
debug_print("📋 方法1: 精确名称匹配...")
|
||||
for vessel_name in possible_names:
|
||||
if vessel_name in G.nodes():
|
||||
debug_print(f"✅ 通过名称匹配找到容器: {vessel_name}")
|
||||
return vessel_name
|
||||
debug_print("⚠️ 精确名称匹配失败,尝试模糊匹配...")
|
||||
|
||||
# 第二步:通过模糊匹配
|
||||
debug_print("📋 方法2: 模糊名称匹配...")
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
node_name = G.nodes[node_id].get('name', '').lower()
|
||||
|
||||
# 检查是否包含溶剂名称
|
||||
if solvent.lower() in node_id.lower() or solvent.lower() in node_name:
|
||||
debug_print(f"✅ 通过模糊匹配找到容器: {node_id}")
|
||||
return node_id
|
||||
debug_print("⚠️ 模糊匹配失败,尝试液体类型匹配...")
|
||||
|
||||
# 第三步:通过液体类型匹配
|
||||
debug_print("📋 方法3: 液体类型匹配...")
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
liquid_type = (liquid.get('liquid_type') or liquid.get('name', '')).lower()
|
||||
reagent_name = vessel_data.get('reagent_name', '').lower()
|
||||
|
||||
if solvent.lower() in liquid_type or solvent.lower() in reagent_name:
|
||||
debug_print(f"✅ 通过液体类型匹配找到容器: {node_id}")
|
||||
return node_id
|
||||
|
||||
# 列出可用容器帮助调试
|
||||
debug_print("📊 显示可用容器信息...")
|
||||
available_containers = []
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
liquid_types = [liquid.get('liquid_type', '') or liquid.get('name', '')
|
||||
for liquid in liquids if isinstance(liquid, dict)]
|
||||
|
||||
available_containers.append({
|
||||
'id': node_id,
|
||||
'name': G.nodes[node_id].get('name', ''),
|
||||
'liquids': liquid_types,
|
||||
'reagent_name': vessel_data.get('reagent_name', '')
|
||||
})
|
||||
|
||||
debug_print(f"📋 可用容器列表 (共{len(available_containers)}个):")
|
||||
for i, container in enumerate(available_containers[:5]): # 只显示前5个
|
||||
debug_print(f" {i+1}. 🥽 {container['id']}: {container['name']}")
|
||||
debug_print(f" 💧 液体: {container['liquids']}")
|
||||
debug_print(f" 🧪 试剂: {container['reagent_name']}")
|
||||
|
||||
if len(available_containers) > 5:
|
||||
debug_print(f" ... 还有 {len(available_containers)-5} 个容器")
|
||||
|
||||
debug_print(f"❌ 找不到溶剂 '{solvent}' 对应的容器")
|
||||
raise ValueError(f"找不到溶剂 '{solvent}' 对应的容器。尝试了: {possible_names[:3]}...")
|
||||
create_action_log = action_log
|
||||
|
||||
def generate_reset_handling_protocol(
|
||||
G: nx.DiGraph,
|
||||
solvent: str,
|
||||
vessel: Optional[str] = None, # 🆕 新增可选vessel参数
|
||||
**kwargs # 接收其他可能的参数但不使用
|
||||
vessel: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成重置处理协议序列 - 支持自定义容器
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为容器和设备
|
||||
solvent: 溶剂名称(从XDL传入)
|
||||
vessel: 目标容器名称(可选,默认为 "main_reactor")
|
||||
**kwargs: 其他可选参数,但不使用
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 修改:支持自定义vessel参数
|
||||
target_vessel = vessel if vessel is not None else "main_reactor" # 默认目标容器
|
||||
volume = 50.0 # 默认体积 50 mL
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print("🚀 开始生成重置处理协议")
|
||||
debug_print(f"📋 输入参数:")
|
||||
debug_print(f" 🧪 溶剂: {solvent}")
|
||||
debug_print(f" 🥽 目标容器: {target_vessel} {'(默认)' if vessel is None else '(指定)'}")
|
||||
debug_print(f" 💧 体积: {volume} mL")
|
||||
debug_print(f" ⚙️ 其他参数: {kwargs}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
target_vessel = vessel if vessel is not None else "main_reactor"
|
||||
volume = 50.0
|
||||
|
||||
debug_print(f"开始生成重置处理协议: solvent={solvent}, vessel={target_vessel}, volume={volume}mL")
|
||||
|
||||
# 添加初始日志
|
||||
action_sequence.append(create_action_log(f"开始重置处理操作 - 容器: {target_vessel}", "🎬"))
|
||||
action_sequence.append(create_action_log(f"使用溶剂: {solvent}", "🧪"))
|
||||
action_sequence.append(create_action_log(f"重置体积: {volume}mL", "💧"))
|
||||
|
||||
action_sequence.append(action_log(f"开始重置处理操作 - 容器: {target_vessel}", "🎬"))
|
||||
action_sequence.append(action_log(f"使用溶剂: {solvent}", "🧪"))
|
||||
action_sequence.append(action_log(f"重置体积: {volume}mL", "💧"))
|
||||
|
||||
if vessel is None:
|
||||
action_sequence.append(create_action_log("使用默认目标容器: main_reactor", "⚙️"))
|
||||
action_sequence.append(action_log("使用默认目标容器: main_reactor", "⚙️"))
|
||||
else:
|
||||
action_sequence.append(create_action_log(f"使用指定目标容器: {vessel}", "🎯"))
|
||||
|
||||
action_sequence.append(action_log(f"使用指定目标容器: {vessel}", "🎯"))
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
debug_print("🔍 步骤1: 验证目标容器...")
|
||||
action_sequence.append(create_action_log("正在验证目标容器...", "🔍"))
|
||||
|
||||
action_sequence.append(action_log("正在验证目标容器...", "🔍"))
|
||||
|
||||
if target_vessel not in G.nodes():
|
||||
debug_print(f"❌ 目标容器 '{target_vessel}' 不存在于系统中!")
|
||||
action_sequence.append(create_action_log(f"目标容器 '{target_vessel}' 不存在", "❌"))
|
||||
action_sequence.append(action_log(f"目标容器 '{target_vessel}' 不存在", "❌"))
|
||||
raise ValueError(f"目标容器 '{target_vessel}' 不存在于系统中")
|
||||
|
||||
debug_print(f"✅ 目标容器 '{target_vessel}' 验证通过")
|
||||
action_sequence.append(create_action_log(f"目标容器验证通过: {target_vessel}", "✅"))
|
||||
|
||||
|
||||
action_sequence.append(action_log(f"目标容器验证通过: {target_vessel}", "✅"))
|
||||
|
||||
# 2. 查找溶剂容器
|
||||
debug_print("🔍 步骤2: 查找溶剂容器...")
|
||||
action_sequence.append(create_action_log("正在查找溶剂容器...", "🔍"))
|
||||
|
||||
action_sequence.append(action_log("正在查找溶剂容器...", "🔍"))
|
||||
|
||||
try:
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
debug_print(f"✅ 找到溶剂容器: {solvent_vessel}")
|
||||
action_sequence.append(create_action_log(f"找到溶剂容器: {solvent_vessel}", "✅"))
|
||||
debug_print(f"找到溶剂容器: {solvent_vessel}")
|
||||
action_sequence.append(action_log(f"找到溶剂容器: {solvent_vessel}", "✅"))
|
||||
except ValueError as e:
|
||||
debug_print(f"❌ 溶剂容器查找失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"溶剂容器查找失败: {str(e)}", "❌"))
|
||||
action_sequence.append(action_log(f"溶剂容器查找失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"无法找到溶剂 '{solvent}': {str(e)}")
|
||||
|
||||
|
||||
# 3. 验证路径存在
|
||||
debug_print("🔍 步骤3: 验证传输路径...")
|
||||
action_sequence.append(create_action_log("正在验证传输路径...", "🛤️"))
|
||||
|
||||
action_sequence.append(action_log("正在验证传输路径...", "🛤️"))
|
||||
|
||||
try:
|
||||
path = nx.shortest_path(G, source=solvent_vessel, target=target_vessel)
|
||||
debug_print(f"✅ 找到路径: {' → '.join(path)}")
|
||||
action_sequence.append(create_action_log(f"传输路径: {' → '.join(path)}", "🛤️"))
|
||||
action_sequence.append(action_log(f"传输路径: {' → '.join(path)}", "🛤️"))
|
||||
except nx.NetworkXNoPath:
|
||||
debug_print(f"❌ 路径不可达: {solvent_vessel} → {target_vessel}")
|
||||
action_sequence.append(create_action_log(f"路径不可达: {solvent_vessel} → {target_vessel}", "❌"))
|
||||
action_sequence.append(action_log(f"路径不可达: {solvent_vessel} → {target_vessel}", "❌"))
|
||||
raise ValueError(f"从溶剂容器 '{solvent_vessel}' 到目标容器 '{target_vessel}' 没有可用路径")
|
||||
|
||||
|
||||
# 4. 使用pump_protocol转移溶剂
|
||||
debug_print("🔍 步骤4: 转移溶剂...")
|
||||
action_sequence.append(create_action_log("开始溶剂转移操作...", "🚰"))
|
||||
|
||||
debug_print(f"🚛 开始转移: {solvent_vessel} → {target_vessel}")
|
||||
debug_print(f"💧 转移体积: {volume} mL")
|
||||
action_sequence.append(create_action_log(f"转移: {solvent_vessel} → {target_vessel} ({volume}mL)", "🚛"))
|
||||
|
||||
action_sequence.append(action_log("开始溶剂转移操作...", "🚰"))
|
||||
action_sequence.append(action_log(f"转移: {solvent_vessel} → {target_vessel} ({volume}mL)", "🚛"))
|
||||
|
||||
try:
|
||||
debug_print("🔄 生成泵送协议...")
|
||||
action_sequence.append(create_action_log("正在生成泵送协议...", "🔄"))
|
||||
|
||||
action_sequence.append(action_log("正在生成泵送协议...", "🔄"))
|
||||
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent_vessel,
|
||||
@@ -256,41 +90,34 @@ def generate_reset_handling_protocol(
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
rinsing_solvent="", # 重置处理不需要清洗
|
||||
rinsing_solvent="",
|
||||
rinsing_volume=0.0,
|
||||
rinsing_repeats=0,
|
||||
solid=False,
|
||||
flowrate=2.5, # 正常流速
|
||||
transfer_flowrate=0.5 # 正常转移流速
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5
|
||||
)
|
||||
|
||||
|
||||
action_sequence.extend(pump_actions)
|
||||
debug_print(f"✅ 泵送协议已添加: {len(pump_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"泵送协议完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
|
||||
debug_print(f"泵送协议已添加: {len(pump_actions)} 个动作")
|
||||
action_sequence.append(action_log(f"泵送协议完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 泵送协议生成失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"泵送协议生成失败: {str(e)}", "❌"))
|
||||
action_sequence.append(action_log(f"泵送协议生成失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"生成泵协议时出错: {str(e)}")
|
||||
|
||||
|
||||
# 5. 等待溶剂稳定
|
||||
debug_print("🔍 步骤5: 等待溶剂稳定...")
|
||||
action_sequence.append(create_action_log("等待溶剂稳定...", "⏳"))
|
||||
|
||||
# 模拟运行时间优化
|
||||
debug_print("⏱️ 检查模拟运行时间限制...")
|
||||
original_wait_time = 10.0 # 原始等待时间
|
||||
simulation_time_limit = 5.0 # 模拟运行时间限制:5秒
|
||||
|
||||
action_sequence.append(action_log("等待溶剂稳定...", "⏳"))
|
||||
|
||||
original_wait_time = 10.0
|
||||
simulation_time_limit = 5.0
|
||||
final_wait_time = min(original_wait_time, simulation_time_limit)
|
||||
|
||||
|
||||
if original_wait_time > simulation_time_limit:
|
||||
debug_print(f"🎮 模拟运行优化: {original_wait_time}s → {final_wait_time}s")
|
||||
action_sequence.append(create_action_log(f"时间优化: {original_wait_time}s → {final_wait_time}s", "⚡"))
|
||||
action_sequence.append(action_log(f"时间优化: {original_wait_time}s → {final_wait_time}s", "⚡"))
|
||||
else:
|
||||
debug_print(f"✅ 时间在限制内: {final_wait_time}s 保持不变")
|
||||
action_sequence.append(create_action_log(f"等待时间: {final_wait_time}s", "⏰"))
|
||||
|
||||
action_sequence.append(action_log(f"等待时间: {final_wait_time}s", "⏰"))
|
||||
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -298,90 +125,50 @@ def generate_reset_handling_protocol(
|
||||
"description": f"等待溶剂 {solvent} 在容器 {target_vessel} 中稳定" + (f" (模拟时间)" if original_wait_time != final_wait_time else "")
|
||||
}
|
||||
})
|
||||
debug_print(f"✅ 稳定等待已添加: {final_wait_time}s")
|
||||
|
||||
# 显示时间调整信息
|
||||
|
||||
if original_wait_time != final_wait_time:
|
||||
debug_print(f"🎭 模拟优化说明: 原计划 {original_wait_time}s,实际模拟 {final_wait_time}s")
|
||||
action_sequence.append(create_action_log("应用模拟时间优化", "🎭"))
|
||||
|
||||
action_sequence.append(action_log("应用模拟时间优化", "🎭"))
|
||||
|
||||
# 总结
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 重置处理协议生成完成!")
|
||||
debug_print(f"📊 总结信息:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f" 🧪 溶剂: {solvent}")
|
||||
debug_print(f" 🥽 源容器: {solvent_vessel}")
|
||||
debug_print(f" 🥽 目标容器: {target_vessel} {'(默认)' if vessel is None else '(指定)'}")
|
||||
debug_print(f" 💧 转移体积: {volume} mL")
|
||||
debug_print(f" ⏱️ 预计总时间: {(final_wait_time + 5):.0f} 秒")
|
||||
debug_print(f" 🎯 操作结果: 已添加 {volume} mL {solvent} 到 {target_vessel}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
# 添加完成日志
|
||||
debug_print(f"重置处理协议生成完成: {len(action_sequence)} 个动作, {solvent_vessel} -> {target_vessel}, {volume}mL")
|
||||
|
||||
summary_msg = f"重置处理完成: {target_vessel} (使用 {volume}mL {solvent})"
|
||||
if vessel is None:
|
||||
summary_msg += " [默认容器]"
|
||||
else:
|
||||
summary_msg += " [指定容器]"
|
||||
|
||||
action_sequence.append(create_action_log(summary_msg, "🎉"))
|
||||
|
||||
|
||||
action_sequence.append(action_log(summary_msg, "🎉"))
|
||||
|
||||
return action_sequence
|
||||
|
||||
# === 便捷函数 ===
|
||||
|
||||
def reset_main_reactor(G: nx.DiGraph, solvent: str = "methanol", **kwargs) -> List[Dict[str, Any]]:
|
||||
"""重置主反应器 (默认行为)"""
|
||||
debug_print(f"🔄 重置主反应器,使用溶剂: {solvent}")
|
||||
return generate_reset_handling_protocol(G, solvent=solvent, vessel=None, **kwargs)
|
||||
|
||||
def reset_custom_vessel(G: nx.DiGraph, vessel: str, solvent: str = "methanol", **kwargs) -> List[Dict[str, Any]]:
|
||||
"""重置指定容器"""
|
||||
debug_print(f"🔄 重置指定容器: {vessel},使用溶剂: {solvent}")
|
||||
return generate_reset_handling_protocol(G, solvent=solvent, vessel=vessel, **kwargs)
|
||||
|
||||
def reset_with_water(G: nx.DiGraph, vessel: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""使用水重置容器"""
|
||||
target = vessel or "main_reactor"
|
||||
debug_print(f"💧 使用水重置容器: {target}")
|
||||
return generate_reset_handling_protocol(G, solvent="water", vessel=vessel, **kwargs)
|
||||
|
||||
def reset_with_methanol(G: nx.DiGraph, vessel: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""使用甲醇重置容器"""
|
||||
target = vessel or "main_reactor"
|
||||
debug_print(f"🧪 使用甲醇重置容器: {target}")
|
||||
return generate_reset_handling_protocol(G, solvent="methanol", vessel=vessel, **kwargs)
|
||||
|
||||
def reset_with_ethanol(G: nx.DiGraph, vessel: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""使用乙醇重置容器"""
|
||||
target = vessel or "main_reactor"
|
||||
debug_print(f"🧪 使用乙醇重置容器: {target}")
|
||||
return generate_reset_handling_protocol(G, solvent="ethanol", vessel=vessel, **kwargs)
|
||||
|
||||
# 测试函数
|
||||
def test_reset_handling_protocol():
|
||||
"""测试重置处理协议"""
|
||||
debug_print("=== 重置处理协议增强中文版测试 ===")
|
||||
|
||||
# 测试溶剂名称
|
||||
debug_print("🧪 测试常用溶剂名称...")
|
||||
test_solvents = ["methanol", "ethanol", "water", "acetone", "dmso"]
|
||||
for solvent in test_solvents:
|
||||
debug_print(f" 🔍 测试溶剂: {solvent}")
|
||||
|
||||
# 测试容器参数
|
||||
debug_print("🥽 测试容器参数...")
|
||||
test_cases = [
|
||||
{"solvent": "methanol", "vessel": None, "desc": "默认容器"},
|
||||
{"solvent": "ethanol", "vessel": "reactor_2", "desc": "指定容器"},
|
||||
{"solvent": "water", "vessel": "flask_1", "desc": "自定义容器"}
|
||||
]
|
||||
|
||||
for case in test_cases:
|
||||
debug_print(f" 🧪 测试案例: {case['desc']} - {case['solvent']} -> {case['vessel'] or 'main_reactor'}")
|
||||
|
||||
debug_print("✅ 测试完成")
|
||||
debug_print("=== 重置处理协议测试 ===")
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_reset_handling_protocol()
|
||||
test_reset_handling_protocol()
|
||||
|
||||
@@ -2,60 +2,54 @@ from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
import re
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel
|
||||
from .utils.resource_helper import get_resource_id, get_resource_data, get_resource_liquid_volume, update_vessel_volume
|
||||
from .utils.logger_util import debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[RUN_COLUMN] {message}")
|
||||
|
||||
def parse_percentage(pct_str: str) -> float:
|
||||
"""
|
||||
解析百分比字符串为数值
|
||||
|
||||
|
||||
Args:
|
||||
pct_str: 百分比字符串(如 "40 %", "40%", "40")
|
||||
|
||||
|
||||
Returns:
|
||||
float: 百分比数值(0-100)
|
||||
"""
|
||||
if not pct_str or not pct_str.strip():
|
||||
return 0.0
|
||||
|
||||
|
||||
pct_str = pct_str.strip().lower()
|
||||
debug_print(f"🔍 解析百分比: '{pct_str}'")
|
||||
|
||||
|
||||
# 移除百分号和空格
|
||||
pct_clean = re.sub(r'[%\s]', '', pct_str)
|
||||
|
||||
# 提取数字
|
||||
|
||||
match = re.search(r'([0-9]*\.?[0-9]+)', pct_clean)
|
||||
if match:
|
||||
value = float(match.group(1))
|
||||
debug_print(f"✅ 百分比解析结果: {value}%")
|
||||
return value
|
||||
|
||||
debug_print(f"⚠️ 无法解析百分比: '{pct_str}',返回0.0")
|
||||
|
||||
debug_print(f"无法解析百分比: '{pct_str}',返回0.0")
|
||||
return 0.0
|
||||
|
||||
def parse_ratio(ratio_str: str) -> tuple:
|
||||
"""
|
||||
解析比例字符串为两个数值
|
||||
|
||||
|
||||
Args:
|
||||
ratio_str: 比例字符串(如 "5:95", "1:1", "40:60")
|
||||
|
||||
|
||||
Returns:
|
||||
tuple: (ratio1, ratio2) 两个比例值
|
||||
tuple: (ratio1, ratio2) 两个比例值(百分比)
|
||||
"""
|
||||
if not ratio_str or not ratio_str.strip():
|
||||
return (50.0, 50.0) # 默认1:1
|
||||
|
||||
return (50.0, 50.0)
|
||||
|
||||
ratio_str = ratio_str.strip()
|
||||
debug_print(f"🔍 解析比例: '{ratio_str}'")
|
||||
|
||||
|
||||
# 支持多种分隔符:: / -
|
||||
if ':' in ratio_str:
|
||||
parts = ratio_str.split(':')
|
||||
@@ -66,101 +60,82 @@ def parse_ratio(ratio_str: str) -> tuple:
|
||||
elif 'to' in ratio_str.lower():
|
||||
parts = ratio_str.lower().split('to')
|
||||
else:
|
||||
debug_print(f"⚠️ 无法解析比例格式: '{ratio_str}',使用默认1:1")
|
||||
debug_print(f"无法解析比例格式: '{ratio_str}',使用默认1:1")
|
||||
return (50.0, 50.0)
|
||||
|
||||
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
ratio1 = float(parts[0].strip())
|
||||
ratio2 = float(parts[1].strip())
|
||||
total = ratio1 + ratio2
|
||||
|
||||
# 转换为百分比
|
||||
|
||||
pct1 = (ratio1 / total) * 100
|
||||
pct2 = (ratio2 / total) * 100
|
||||
|
||||
debug_print(f"✅ 比例解析结果: {ratio1}:{ratio2} -> {pct1:.1f}%:{pct2:.1f}%")
|
||||
|
||||
return (pct1, pct2)
|
||||
except ValueError as e:
|
||||
debug_print(f"⚠️ 比例数值转换失败: {str(e)}")
|
||||
|
||||
debug_print(f"⚠️ 比例解析失败,使用默认1:1")
|
||||
debug_print(f"比例数值转换失败: {str(e)}")
|
||||
|
||||
debug_print(f"比例解析失败,使用默认1:1")
|
||||
return (50.0, 50.0)
|
||||
|
||||
def parse_rf_value(rf_str: str) -> float:
|
||||
"""
|
||||
解析Rf值字符串
|
||||
|
||||
|
||||
Args:
|
||||
rf_str: Rf值字符串(如 "0.3", "0.45", "?")
|
||||
|
||||
|
||||
Returns:
|
||||
float: Rf值(0-1)
|
||||
"""
|
||||
if not rf_str or not rf_str.strip():
|
||||
return 0.3 # 默认Rf值
|
||||
|
||||
return 0.3
|
||||
|
||||
rf_str = rf_str.strip().lower()
|
||||
debug_print(f"🔍 解析Rf值: '{rf_str}'")
|
||||
|
||||
# 处理未知Rf值
|
||||
|
||||
if rf_str in ['?', 'unknown', 'tbd', 'to be determined']:
|
||||
default_rf = 0.3
|
||||
debug_print(f"❓ 检测到未知Rf值,使用默认值: {default_rf}")
|
||||
return default_rf
|
||||
|
||||
# 提取数字
|
||||
return 0.3
|
||||
|
||||
match = re.search(r'([0-9]*\.?[0-9]+)', rf_str)
|
||||
if match:
|
||||
value = float(match.group(1))
|
||||
# 确保Rf值在0-1范围内
|
||||
if value > 1.0:
|
||||
value = value / 100.0 # 可能是百分比形式
|
||||
value = max(0.0, min(1.0, value)) # 限制在0-1范围
|
||||
debug_print(f"✅ Rf值解析结果: {value}")
|
||||
value = value / 100.0
|
||||
value = max(0.0, min(1.0, value))
|
||||
return value
|
||||
|
||||
debug_print(f"⚠️ 无法解析Rf值: '{rf_str}',使用默认值0.3")
|
||||
|
||||
return 0.3
|
||||
|
||||
def find_column_device(G: nx.DiGraph) -> str:
|
||||
"""查找柱层析设备"""
|
||||
debug_print("🔍 查找柱层析设备...")
|
||||
|
||||
# 查找虚拟柱设备
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if 'virtual_column' in node_class.lower() or 'column' in node_class.lower():
|
||||
debug_print(f"🎉 找到柱层析设备: {node} ✨")
|
||||
debug_print(f"找到柱层析设备: {node}")
|
||||
return node
|
||||
|
||||
# 如果没有找到,尝试创建虚拟设备名称
|
||||
|
||||
possible_names = ['column_1', 'virtual_column_1', 'chromatography_column_1']
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
debug_print(f"🎉 找到柱设备: {name} ✨")
|
||||
debug_print(f"找到柱设备: {name}")
|
||||
return name
|
||||
|
||||
debug_print("⚠️ 未找到柱层析设备,将使用pump protocol直接转移")
|
||||
|
||||
debug_print("未找到柱层析设备,将使用pump protocol直接转移")
|
||||
return ""
|
||||
|
||||
def find_column_vessel(G: nx.DiGraph, column: str) -> str:
|
||||
"""查找柱容器"""
|
||||
debug_print(f"🔍 查找柱容器: '{column}'")
|
||||
|
||||
# 直接检查column参数是否是容器
|
||||
if column in G.nodes():
|
||||
node_type = G.nodes[column].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"🎉 找到柱容器: {column} ✨")
|
||||
return column
|
||||
|
||||
# 尝试常见的命名规则
|
||||
|
||||
possible_names = [
|
||||
f"column_{column}",
|
||||
f"{column}_column",
|
||||
f"{column}_column",
|
||||
f"vessel_{column}",
|
||||
f"{column}_vessel",
|
||||
"column_vessel",
|
||||
@@ -169,211 +144,25 @@ def find_column_vessel(G: nx.DiGraph, column: str) -> str:
|
||||
"preparative_column",
|
||||
"column"
|
||||
]
|
||||
|
||||
|
||||
for vessel_name in possible_names:
|
||||
if vessel_name in G.nodes():
|
||||
node_type = G.nodes[vessel_name].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"🎉 找到柱容器: {vessel_name} ✨")
|
||||
return vessel_name
|
||||
|
||||
debug_print(f"⚠️ 未找到柱容器,将直接在源容器中进行分离")
|
||||
|
||||
return ""
|
||||
|
||||
def find_solvent_vessel(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""查找溶剂容器 - 增强版"""
|
||||
if not solvent or not solvent.strip():
|
||||
return ""
|
||||
|
||||
solvent = solvent.strip().replace(' ', '_').lower()
|
||||
debug_print(f"🔍 查找溶剂容器: '{solvent}'")
|
||||
|
||||
# 🔧 方法1:直接搜索 data.reagent_name
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node].get('data', {})
|
||||
node_type = G.nodes[node].get('type', '')
|
||||
|
||||
# 只搜索容器类型的节点
|
||||
if node_type == 'container':
|
||||
reagent_name = node_data.get('reagent_name', '').lower()
|
||||
reagent_config = G.nodes[node].get('config', {}).get('reagent', '').lower()
|
||||
|
||||
# 检查 data.reagent_name 和 config.reagent
|
||||
if reagent_name == solvent or reagent_config == solvent:
|
||||
debug_print(f"🎉 通过reagent_name找到溶剂容器: {node} (reagent: {reagent_name or reagent_config}) ✨")
|
||||
return node
|
||||
|
||||
# 模糊匹配 reagent_name
|
||||
if solvent in reagent_name or reagent_name in solvent:
|
||||
debug_print(f"🎉 通过reagent_name模糊匹配到溶剂容器: {node} (reagent: {reagent_name}) ✨")
|
||||
return node
|
||||
|
||||
if solvent in reagent_config or reagent_config in solvent:
|
||||
debug_print(f"🎉 通过config.reagent模糊匹配到溶剂容器: {node} (reagent: {reagent_config}) ✨")
|
||||
return node
|
||||
|
||||
# 🔧 方法2:常见的溶剂容器命名规则
|
||||
possible_names = [
|
||||
f"flask_{solvent}",
|
||||
f"bottle_{solvent}",
|
||||
f"reagent_{solvent}",
|
||||
f"{solvent}_bottle",
|
||||
f"{solvent}_flask",
|
||||
f"solvent_{solvent}",
|
||||
f"reagent_bottle_{solvent}"
|
||||
]
|
||||
|
||||
for vessel_name in possible_names:
|
||||
if vessel_name in G.nodes():
|
||||
node_type = G.nodes[vessel_name].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"🎉 通过命名规则找到溶剂容器: {vessel_name} ✨")
|
||||
return vessel_name
|
||||
|
||||
# 🔧 方法3:节点名称模糊匹配
|
||||
for node in G.nodes():
|
||||
node_type = G.nodes[node].get('type', '')
|
||||
if node_type == 'container':
|
||||
if ('flask_' in node or 'bottle_' in node or 'reagent_' in node) and solvent in node.lower():
|
||||
debug_print(f"🎉 通过节点名称模糊匹配到溶剂容器: {node} ✨")
|
||||
return node
|
||||
|
||||
# 🔧 方法4:特殊溶剂名称映射
|
||||
solvent_mapping = {
|
||||
'dmf': ['dmf', 'dimethylformamide', 'n,n-dimethylformamide'],
|
||||
'ethyl_acetate': ['ethyl_acetate', 'ethylacetate', 'etoac', 'ea'],
|
||||
'hexane': ['hexane', 'hexanes', 'n-hexane'],
|
||||
'methanol': ['methanol', 'meoh', 'ch3oh'],
|
||||
'water': ['water', 'h2o', 'distilled_water'],
|
||||
'acetone': ['acetone', 'ch3coch3', '2-propanone'],
|
||||
'dichloromethane': ['dichloromethane', 'dcm', 'ch2cl2', 'methylene_chloride'],
|
||||
'chloroform': ['chloroform', 'chcl3', 'trichloromethane']
|
||||
}
|
||||
|
||||
# 查找映射的同义词
|
||||
for canonical_name, synonyms in solvent_mapping.items():
|
||||
if solvent in synonyms:
|
||||
debug_print(f"🔍 检测到溶剂同义词: '{solvent}' -> '{canonical_name}'")
|
||||
return find_solvent_vessel(G, canonical_name) # 递归搜索
|
||||
|
||||
debug_print(f"⚠️ 未找到溶剂 '{solvent}' 的容器")
|
||||
return ""
|
||||
|
||||
def get_vessel_liquid_volume(vessel: dict) -> float:
|
||||
"""
|
||||
获取容器中的液体体积 - 支持vessel字典
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
|
||||
Returns:
|
||||
float: 液体体积(mL)
|
||||
"""
|
||||
if not vessel or "data" not in vessel:
|
||||
debug_print(f"⚠️ 容器数据为空,返回 0.0mL")
|
||||
return 0.0
|
||||
|
||||
vessel_data = vessel["data"]
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
debug_print(f"🔍 读取容器 '{vessel_id}' 体积数据: {vessel_data}")
|
||||
|
||||
# 检查liquid_volume字段
|
||||
if "liquid_volume" in vessel_data:
|
||||
liquid_volume = vessel_data["liquid_volume"]
|
||||
|
||||
# 处理列表格式
|
||||
if isinstance(liquid_volume, list):
|
||||
if len(liquid_volume) > 0:
|
||||
volume = liquid_volume[0]
|
||||
if isinstance(volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (列表格式)")
|
||||
return float(volume)
|
||||
|
||||
# 处理直接数值格式
|
||||
elif isinstance(liquid_volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {liquid_volume}mL (数值格式)")
|
||||
return float(liquid_volume)
|
||||
|
||||
# 检查其他可能的体积字段
|
||||
volume_keys = ['current_volume', 'total_volume', 'volume']
|
||||
for key in volume_keys:
|
||||
if key in vessel_data:
|
||||
try:
|
||||
volume = float(vessel_data[key])
|
||||
if volume > 0:
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (字段: {key})")
|
||||
return volume
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
debug_print(f"⚠️ 无法获取容器 '{vessel_id}' 的体积,返回默认值 50.0mL")
|
||||
return 50.0
|
||||
|
||||
def update_vessel_volume(vessel: dict, G: nx.DiGraph, new_volume: float, description: str = "") -> None:
|
||||
"""
|
||||
更新容器体积(同时更新vessel字典和图节点)
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
G: 网络图
|
||||
new_volume: 新体积
|
||||
description: 更新描述
|
||||
"""
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
if description:
|
||||
debug_print(f"🔧 更新容器体积 - {description}")
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
if "data" in vessel:
|
||||
if "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [new_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"] = {"liquid_volume": new_volume}
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
debug_print(f"📊 容器 '{vessel_id}' 体积已更新为: {new_volume:.2f}mL")
|
||||
|
||||
def calculate_solvent_volumes(total_volume: float, pct1: float, pct2: float) -> tuple:
|
||||
"""根据百分比计算溶剂体积"""
|
||||
volume1 = (total_volume * pct1) / 100.0
|
||||
volume2 = (total_volume * pct2) / 100.0
|
||||
|
||||
debug_print(f"🧮 溶剂体积计算: 总体积{total_volume}mL")
|
||||
debug_print(f" - 溶剂1: {pct1}% = {volume1}mL")
|
||||
debug_print(f" - 溶剂2: {pct2}% = {volume2}mL")
|
||||
|
||||
return (volume1, volume2)
|
||||
|
||||
def generate_run_column_protocol(
|
||||
G: nx.DiGraph,
|
||||
from_vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
to_vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
from_vessel: dict,
|
||||
to_vessel: dict,
|
||||
column: str,
|
||||
rf: str = "",
|
||||
pct1: str = "",
|
||||
@@ -385,7 +174,7 @@ def generate_run_column_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成柱层析分离的协议序列 - 支持vessel字典和体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为设备和容器,边为流体管道
|
||||
from_vessel: 源容器字典(从XDL传入)
|
||||
@@ -398,173 +187,112 @@ def generate_run_column_protocol(
|
||||
solvent2: 第二种溶剂名称(可选)
|
||||
ratio: 溶剂比例(如 "5:95",可选,优先级高于pct1/pct2)
|
||||
**kwargs: 其他可选参数
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 柱层析分离操作的动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
|
||||
debug_print("🏛️" * 20)
|
||||
debug_print("🚀 开始生成柱层析协议(支持vessel字典和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 from_vessel: {from_vessel} (ID: {from_vessel_id})")
|
||||
debug_print(f" 🥽 to_vessel: {to_vessel} (ID: {to_vessel_id})")
|
||||
debug_print(f" 🏛️ column: '{column}'")
|
||||
debug_print(f" 📊 rf: '{rf}'")
|
||||
debug_print(f" 🧪 溶剂配比: pct1='{pct1}', pct2='{pct2}', ratio='{ratio}'")
|
||||
debug_print(f" 🧪 溶剂名称: solvent1='{solvent1}', solvent2='{solvent2}'")
|
||||
debug_print("🏛️" * 20)
|
||||
|
||||
debug_print(f"开始生成柱层析协议: {from_vessel_id} -> {to_vessel_id}, column={column}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 新增:记录柱层析前的容器状态
|
||||
debug_print("🔍 记录柱层析前容器状态...")
|
||||
original_from_volume = get_vessel_liquid_volume(from_vessel)
|
||||
original_to_volume = get_vessel_liquid_volume(to_vessel)
|
||||
|
||||
debug_print(f"📊 柱层析前状态:")
|
||||
debug_print(f" - 源容器 {from_vessel_id}: {original_from_volume:.2f}mL")
|
||||
debug_print(f" - 目标容器 {to_vessel_id}: {original_to_volume:.2f}mL")
|
||||
|
||||
|
||||
# 记录柱层析前的容器状态
|
||||
original_from_volume = get_resource_liquid_volume(from_vessel)
|
||||
original_to_volume = get_resource_liquid_volume(to_vessel)
|
||||
|
||||
# === 参数验证 ===
|
||||
debug_print("📍 步骤1: 参数验证...")
|
||||
|
||||
if not from_vessel_id: # 🔧 使用 from_vessel_id
|
||||
if not from_vessel_id:
|
||||
raise ValueError("from_vessel 参数不能为空")
|
||||
if not to_vessel_id: # 🔧 使用 to_vessel_id
|
||||
if not to_vessel_id:
|
||||
raise ValueError("to_vessel 参数不能为空")
|
||||
if not column:
|
||||
raise ValueError("column 参数不能为空")
|
||||
|
||||
if from_vessel_id not in G.nodes(): # 🔧 使用 from_vessel_id
|
||||
|
||||
if from_vessel_id not in G.nodes():
|
||||
raise ValueError(f"源容器 '{from_vessel_id}' 不存在于系统中")
|
||||
if to_vessel_id not in G.nodes(): # 🔧 使用 to_vessel_id
|
||||
if to_vessel_id not in G.nodes():
|
||||
raise ValueError(f"目标容器 '{to_vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print("✅ 基本参数验证通过")
|
||||
|
||||
|
||||
# === 参数解析 ===
|
||||
debug_print("📍 步骤2: 参数解析...")
|
||||
|
||||
# 解析Rf值
|
||||
final_rf = parse_rf_value(rf)
|
||||
debug_print(f"🎯 最终Rf值: {final_rf}")
|
||||
|
||||
# 解析溶剂比例(ratio优先级高于pct1/pct2)
|
||||
|
||||
if ratio and ratio.strip():
|
||||
final_pct1, final_pct2 = parse_ratio(ratio)
|
||||
debug_print(f"📊 使用ratio参数: {final_pct1:.1f}% : {final_pct2:.1f}%")
|
||||
else:
|
||||
final_pct1 = parse_percentage(pct1) if pct1 else 50.0
|
||||
final_pct2 = parse_percentage(pct2) if pct2 else 50.0
|
||||
|
||||
# 如果百分比和不是100%,进行归一化
|
||||
|
||||
total_pct = final_pct1 + final_pct2
|
||||
if total_pct == 0:
|
||||
final_pct1, final_pct2 = 50.0, 50.0
|
||||
elif total_pct != 100.0:
|
||||
final_pct1 = (final_pct1 / total_pct) * 100
|
||||
final_pct2 = (final_pct2 / total_pct) * 100
|
||||
|
||||
debug_print(f"📊 使用百分比参数: {final_pct1:.1f}% : {final_pct2:.1f}%")
|
||||
|
||||
# 设置默认溶剂(如果未指定)
|
||||
|
||||
final_solvent1 = solvent1.strip() if solvent1 else "ethyl_acetate"
|
||||
final_solvent2 = solvent2.strip() if solvent2 else "hexane"
|
||||
|
||||
debug_print(f"🧪 最终溶剂: {final_solvent1} : {final_solvent2}")
|
||||
|
||||
|
||||
debug_print(f"参数: rf={final_rf}, 溶剂={final_solvent1}:{final_solvent2} = {final_pct1:.1f}%:{final_pct2:.1f}%")
|
||||
|
||||
# === 查找设备和容器 ===
|
||||
debug_print("📍 步骤3: 查找设备和容器...")
|
||||
|
||||
# 查找柱层析设备
|
||||
column_device_id = find_column_device(G)
|
||||
|
||||
# 查找柱容器
|
||||
column_vessel = find_column_vessel(G, column)
|
||||
|
||||
# 查找溶剂容器
|
||||
solvent1_vessel = find_solvent_vessel(G, final_solvent1)
|
||||
solvent2_vessel = find_solvent_vessel(G, final_solvent2)
|
||||
|
||||
debug_print(f"🔧 设备映射:")
|
||||
debug_print(f" - 柱设备: '{column_device_id}'")
|
||||
debug_print(f" - 柱容器: '{column_vessel}'")
|
||||
debug_print(f" - 溶剂1容器: '{solvent1_vessel}'")
|
||||
debug_print(f" - 溶剂2容器: '{solvent2_vessel}'")
|
||||
|
||||
|
||||
# === 获取源容器体积 ===
|
||||
debug_print("📍 步骤4: 获取源容器体积...")
|
||||
|
||||
source_volume = original_from_volume
|
||||
if source_volume <= 0:
|
||||
source_volume = 50.0 # 默认体积
|
||||
debug_print(f"⚠️ 无法获取源容器体积,使用默认值: {source_volume}mL")
|
||||
else:
|
||||
debug_print(f"✅ 源容器体积: {source_volume}mL")
|
||||
|
||||
source_volume = 50.0
|
||||
|
||||
# === 计算溶剂体积 ===
|
||||
debug_print("📍 步骤5: 计算溶剂体积...")
|
||||
|
||||
# 洗脱溶剂通常是样品体积的2-5倍
|
||||
total_elution_volume = source_volume * 3.0
|
||||
solvent1_volume, solvent2_volume = calculate_solvent_volumes(
|
||||
total_elution_volume, final_pct1, final_pct2
|
||||
)
|
||||
|
||||
|
||||
# === 执行柱层析流程 ===
|
||||
debug_print("📍 步骤6: 执行柱层析流程...")
|
||||
|
||||
# 🔧 新增:体积变化跟踪变量
|
||||
current_from_volume = source_volume
|
||||
current_to_volume = original_to_volume
|
||||
current_column_volume = 0.0
|
||||
|
||||
|
||||
try:
|
||||
# 步骤6.1: 样品上柱(如果有独立的柱容器)
|
||||
if column_vessel and column_vessel != from_vessel_id: # 🔧 使用 from_vessel_id
|
||||
debug_print(f"📍 6.1: 样品上柱 - {source_volume}mL 从 {from_vessel_id} 到 {column_vessel}")
|
||||
|
||||
# 步骤1: 样品上柱
|
||||
if column_vessel and column_vessel != from_vessel_id:
|
||||
try:
|
||||
sample_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=from_vessel_id, # 🔧 使用 from_vessel_id
|
||||
from_vessel=from_vessel_id,
|
||||
to_vessel=column_vessel,
|
||||
volume=source_volume,
|
||||
flowrate=1.0, # 慢速上柱
|
||||
flowrate=1.0,
|
||||
transfer_flowrate=0.5,
|
||||
rinsing_solvent="", # 暂不冲洗
|
||||
rinsing_solvent="",
|
||||
rinsing_volume=0.0,
|
||||
rinsing_repeats=0
|
||||
)
|
||||
action_sequence.extend(sample_transfer_actions)
|
||||
debug_print(f"✅ 样品上柱完成,添加了 {len(sample_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 样品转移到柱上
|
||||
current_from_volume = 0.0 # 源容器体积变为0
|
||||
current_column_volume = source_volume # 柱容器体积增加
|
||||
|
||||
|
||||
current_from_volume = 0.0
|
||||
current_column_volume = source_volume
|
||||
|
||||
update_vessel_volume(from_vessel, G, current_from_volume, "样品上柱后,源容器清空")
|
||||
|
||||
# 如果柱容器在图中,也更新其体积
|
||||
|
||||
if column_vessel in G.nodes():
|
||||
if 'data' not in G.nodes[column_vessel]:
|
||||
G.nodes[column_vessel]['data'] = {}
|
||||
G.nodes[column_vessel]['data']['liquid_volume'] = current_column_volume
|
||||
debug_print(f"📊 柱容器 '{column_vessel}' 体积更新为: {current_column_volume:.2f}mL")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 样品上柱失败: {str(e)}")
|
||||
|
||||
# 步骤6.2: 添加洗脱溶剂1(如果有溶剂容器)
|
||||
debug_print(f"样品上柱失败: {str(e)}")
|
||||
|
||||
# 步骤2: 添加洗脱溶剂1
|
||||
if solvent1_vessel and solvent1_volume > 0:
|
||||
debug_print(f"📍 6.2: 添加洗脱溶剂1 - {solvent1_volume:.1f}mL {final_solvent1}")
|
||||
|
||||
try:
|
||||
target_vessel = column_vessel if column_vessel else from_vessel_id # 🔧 使用 from_vessel_id
|
||||
target_vessel = column_vessel if column_vessel else from_vessel_id
|
||||
solvent1_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent1_vessel,
|
||||
@@ -574,27 +302,22 @@ def generate_run_column_protocol(
|
||||
transfer_flowrate=1.0
|
||||
)
|
||||
action_sequence.extend(solvent1_transfer_actions)
|
||||
debug_print(f"✅ 溶剂1添加完成,添加了 {len(solvent1_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 添加溶剂1
|
||||
|
||||
if target_vessel == column_vessel:
|
||||
current_column_volume += solvent1_volume
|
||||
if column_vessel in G.nodes():
|
||||
G.nodes[column_vessel]['data']['liquid_volume'] = current_column_volume
|
||||
debug_print(f"📊 柱容器体积增加: +{solvent1_volume:.2f}mL = {current_column_volume:.2f}mL")
|
||||
elif target_vessel == from_vessel_id:
|
||||
current_from_volume += solvent1_volume
|
||||
update_vessel_volume(from_vessel, G, current_from_volume, "添加溶剂1后")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 溶剂1添加失败: {str(e)}")
|
||||
|
||||
# 步骤6.3: 添加洗脱溶剂2(如果有溶剂容器)
|
||||
debug_print(f"溶剂1添加失败: {str(e)}")
|
||||
|
||||
# 步骤3: 添加洗脱溶剂2
|
||||
if solvent2_vessel and solvent2_volume > 0:
|
||||
debug_print(f"📍 6.3: 添加洗脱溶剂2 - {solvent2_volume:.1f}mL {final_solvent2}")
|
||||
|
||||
try:
|
||||
target_vessel = column_vessel if column_vessel else from_vessel_id # 🔧 使用 from_vessel_id
|
||||
target_vessel = column_vessel if column_vessel else from_vessel_id
|
||||
solvent2_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent2_vessel,
|
||||
@@ -604,31 +327,26 @@ def generate_run_column_protocol(
|
||||
transfer_flowrate=1.0
|
||||
)
|
||||
action_sequence.extend(solvent2_transfer_actions)
|
||||
debug_print(f"✅ 溶剂2添加完成,添加了 {len(solvent2_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 添加溶剂2
|
||||
|
||||
if target_vessel == column_vessel:
|
||||
current_column_volume += solvent2_volume
|
||||
if column_vessel in G.nodes():
|
||||
G.nodes[column_vessel]['data']['liquid_volume'] = current_column_volume
|
||||
debug_print(f"📊 柱容器体积增加: +{solvent2_volume:.2f}mL = {current_column_volume:.2f}mL")
|
||||
elif target_vessel == from_vessel_id:
|
||||
current_from_volume += solvent2_volume
|
||||
update_vessel_volume(from_vessel, G, current_from_volume, "添加溶剂2后")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 溶剂2添加失败: {str(e)}")
|
||||
|
||||
# 步骤6.4: 使用柱层析设备执行分离(如果有设备)
|
||||
debug_print(f"溶剂2添加失败: {str(e)}")
|
||||
|
||||
# 步骤4: 使用柱层析设备执行分离
|
||||
if column_device_id:
|
||||
debug_print(f"📍 6.4: 使用柱层析设备执行分离")
|
||||
|
||||
column_separation_action = {
|
||||
"device_id": column_device_id,
|
||||
"action_name": "run_column",
|
||||
"action_kwargs": {
|
||||
"from_vessel": from_vessel_id, # 🔧 使用 from_vessel_id
|
||||
"to_vessel": to_vessel_id, # 🔧 使用 to_vessel_id
|
||||
"from_vessel": from_vessel_id,
|
||||
"to_vessel": to_vessel_id,
|
||||
"column": column,
|
||||
"rf": rf,
|
||||
"pct1": pct1,
|
||||
@@ -639,85 +357,65 @@ def generate_run_column_protocol(
|
||||
}
|
||||
}
|
||||
action_sequence.append(column_separation_action)
|
||||
debug_print(f"✅ 柱层析设备动作已添加")
|
||||
|
||||
# 等待分离完成
|
||||
separation_time = max(30, min(120, int(total_elution_volume / 2))) # 30-120秒,基于体积
|
||||
|
||||
separation_time = max(30, min(120, int(total_elution_volume / 2)))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": separation_time}
|
||||
})
|
||||
debug_print(f"✅ 等待分离完成: {separation_time}秒")
|
||||
|
||||
# 步骤6.5: 产物收集(从柱容器到目标容器)
|
||||
if column_vessel and column_vessel != to_vessel_id: # 🔧 使用 to_vessel_id
|
||||
debug_print(f"📍 6.5: 产物收集 - 从 {column_vessel} 到 {to_vessel_id}")
|
||||
|
||||
|
||||
# 步骤5: 产物收集
|
||||
if column_vessel and column_vessel != to_vessel_id:
|
||||
try:
|
||||
# 估算产物体积(原始样品体积的70-90%,收率考虑)
|
||||
product_volume = source_volume * 0.8
|
||||
|
||||
|
||||
product_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=column_vessel,
|
||||
to_vessel=to_vessel_id, # 🔧 使用 to_vessel_id
|
||||
to_vessel=to_vessel_id,
|
||||
volume=product_volume,
|
||||
flowrate=1.5,
|
||||
transfer_flowrate=0.8
|
||||
)
|
||||
action_sequence.extend(product_transfer_actions)
|
||||
debug_print(f"✅ 产物收集完成,添加了 {len(product_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 产物收集到目标容器
|
||||
|
||||
current_to_volume += product_volume
|
||||
current_column_volume -= product_volume # 柱容器体积减少
|
||||
|
||||
current_column_volume -= product_volume
|
||||
|
||||
update_vessel_volume(to_vessel, G, current_to_volume, "产物收集后")
|
||||
|
||||
# 更新柱容器体积
|
||||
|
||||
if column_vessel in G.nodes():
|
||||
G.nodes[column_vessel]['data']['liquid_volume'] = max(0.0, current_column_volume)
|
||||
debug_print(f"📊 柱容器体积减少: -{product_volume:.2f}mL = {current_column_volume:.2f}mL")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 产物收集失败: {str(e)}")
|
||||
|
||||
# 步骤6.6: 如果没有独立的柱设备和容器,执行简化的直接转移
|
||||
debug_print(f"产物收集失败: {str(e)}")
|
||||
|
||||
# 步骤6: 简化模式 - 直接转移
|
||||
if not column_device_id and not column_vessel:
|
||||
debug_print(f"📍 6.6: 简化模式 - 直接转移 {source_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}")
|
||||
|
||||
try:
|
||||
direct_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=from_vessel_id, # 🔧 使用 from_vessel_id
|
||||
to_vessel=to_vessel_id, # 🔧 使用 to_vessel_id
|
||||
from_vessel=from_vessel_id,
|
||||
to_vessel=to_vessel_id,
|
||||
volume=source_volume,
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=1.0
|
||||
)
|
||||
action_sequence.extend(direct_transfer_actions)
|
||||
debug_print(f"✅ 直接转移完成,添加了 {len(direct_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 直接转移
|
||||
current_from_volume = 0.0 # 源容器清空
|
||||
current_to_volume += source_volume # 目标容器增加
|
||||
|
||||
|
||||
current_from_volume = 0.0
|
||||
current_to_volume += source_volume
|
||||
|
||||
update_vessel_volume(from_vessel, G, current_from_volume, "直接转移后,源容器清空")
|
||||
update_vessel_volume(to_vessel, G, current_to_volume, "直接转移后,目标容器增加")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 直接转移失败: {str(e)}")
|
||||
|
||||
debug_print(f"直接转移失败: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 协议生成失败: {str(e)} 😭")
|
||||
|
||||
# 不添加不确定的动作,直接让action_sequence保持为空列表
|
||||
# action_sequence 已经在函数开始时初始化为 []
|
||||
|
||||
# 确保至少有一个有效的动作,如果完全失败就返回空列表
|
||||
debug_print(f"协议生成失败: {str(e)}")
|
||||
|
||||
if not action_sequence:
|
||||
debug_print("⚠️ 没有生成任何有效动作")
|
||||
# 可以选择返回空列表或添加一个基本的等待动作
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -725,83 +423,50 @@ def generate_run_column_protocol(
|
||||
"description": "柱层析协议执行完成"
|
||||
}
|
||||
})
|
||||
|
||||
# 🔧 新增:柱层析完成后的最终状态报告
|
||||
final_from_volume = get_vessel_liquid_volume(from_vessel)
|
||||
final_to_volume = get_vessel_liquid_volume(to_vessel)
|
||||
|
||||
# 🎊 总结
|
||||
debug_print("🏛️" * 20)
|
||||
debug_print(f"🎉 柱层析协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f"🥽 路径: {from_vessel_id} → {to_vessel_id}")
|
||||
debug_print(f"🏛️ 柱子: {column}")
|
||||
debug_print(f"🧪 溶剂: {final_solvent1}:{final_solvent2} = {final_pct1:.1f}%:{final_pct2:.1f}%")
|
||||
debug_print(f"📊 体积变化统计:")
|
||||
debug_print(f" 源容器 {from_vessel_id}:")
|
||||
debug_print(f" - 柱层析前: {original_from_volume:.2f}mL")
|
||||
debug_print(f" - 柱层析后: {final_from_volume:.2f}mL")
|
||||
debug_print(f" 目标容器 {to_vessel_id}:")
|
||||
debug_print(f" - 柱层析前: {original_to_volume:.2f}mL")
|
||||
debug_print(f" - 柱层析后: {final_to_volume:.2f}mL")
|
||||
debug_print(f" - 收集体积: {final_to_volume - original_to_volume:.2f}mL")
|
||||
debug_print(f"⏱️ 预计总时间: {len(action_sequence) * 5:.0f} 秒 ⌛")
|
||||
debug_print("🏛️" * 20)
|
||||
|
||||
|
||||
final_from_volume = get_resource_liquid_volume(from_vessel)
|
||||
final_to_volume = get_resource_liquid_volume(to_vessel)
|
||||
|
||||
debug_print(f"柱层析协议生成完成: {len(action_sequence)} 个动作, {from_vessel_id} -> {to_vessel_id}, 收集={final_to_volume - original_to_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
# 🔧 新增:便捷函数
|
||||
def generate_ethyl_acetate_hexane_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
# 便捷函数
|
||||
def generate_ethyl_acetate_hexane_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str, ratio: str = "30:70") -> List[Dict[str, Any]]:
|
||||
"""乙酸乙酯-己烷柱层析(常用组合)"""
|
||||
from_vessel_id = from_vessel["id"]
|
||||
to_vessel_id = to_vessel["id"]
|
||||
debug_print(f"🧪⛽ 乙酸乙酯-己烷柱层析: {from_vessel_id} → {to_vessel_id} @ {ratio}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="ethyl_acetate", solvent2="hexane", ratio=ratio)
|
||||
|
||||
def generate_methanol_dcm_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
def generate_methanol_dcm_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str, ratio: str = "5:95") -> List[Dict[str, Any]]:
|
||||
"""甲醇-二氯甲烷柱层析"""
|
||||
from_vessel_id = from_vessel["id"]
|
||||
to_vessel_id = to_vessel["id"]
|
||||
debug_print(f"🧪🧪 甲醇-DCM柱层析: {from_vessel_id} → {to_vessel_id} @ {ratio}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="methanol", solvent2="dichloromethane", ratio=ratio)
|
||||
|
||||
def generate_gradient_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str, start_ratio: str = "10:90",
|
||||
def generate_gradient_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str, start_ratio: str = "10:90",
|
||||
end_ratio: str = "50:50") -> List[Dict[str, Any]]:
|
||||
"""梯度洗脱柱层析(中等比例)"""
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"📈 梯度柱层析: {from_vessel_id} → {to_vessel_id} ({start_ratio} → {end_ratio})")
|
||||
# 使用中间比例作为近似
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column, ratio="30:70")
|
||||
|
||||
def generate_polar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
def generate_polar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str) -> List[Dict[str, Any]]:
|
||||
"""极性化合物柱层析(高极性溶剂比例)"""
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"⚡ 极性化合物柱层析: {from_vessel_id} → {to_vessel_id}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="ethyl_acetate", solvent2="hexane", ratio="70:30")
|
||||
|
||||
def generate_nonpolar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
def generate_nonpolar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str) -> List[Dict[str, Any]]:
|
||||
"""非极性化合物柱层析(低极性溶剂比例)"""
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"🛢️ 非极性化合物柱层析: {from_vessel_id} → {to_vessel_id}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="ethyl_acetate", solvent2="hexane", ratio="5:95")
|
||||
|
||||
# 测试函数
|
||||
def test_run_column_protocol():
|
||||
"""测试柱层析协议"""
|
||||
debug_print("🧪 === RUN COLUMN PROTOCOL 测试 === ✨")
|
||||
debug_print("✅ 测试完成 🎉")
|
||||
debug_print("=== RUN COLUMN PROTOCOL 测试 ===")
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_run_column_protocol()
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
from functools import partial
|
||||
|
||||
import networkx as nx
|
||||
import re
|
||||
import logging
|
||||
import sys
|
||||
from typing import List, Dict, Any, Union
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import action_log
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel, find_connected_stirrer
|
||||
from .utils.resource_helper import get_resource_liquid_volume, update_vessel_volume
|
||||
from .utils.logger_util import debug_print, action_log
|
||||
from .utils.unit_parser import parse_volume_input
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 确保输出编码为UTF-8
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出函数 - 支持中文"""
|
||||
try:
|
||||
# 确保消息是字符串格式
|
||||
safe_message = str(message)
|
||||
logger.info(f"[SEPARATE] {safe_message}")
|
||||
except UnicodeEncodeError:
|
||||
# 如果编码失败,尝试替换不支持的字符
|
||||
safe_message = str(message).encode('utf-8', errors='replace').decode('utf-8')
|
||||
logger.info(f"[SEPARATE] {safe_message}")
|
||||
except Exception as e:
|
||||
# 最后的安全措施
|
||||
fallback_message = f"日志输出错误: {repr(message)}"
|
||||
logger.info(f"[SEPARATE] {fallback_message}")
|
||||
|
||||
create_action_log = partial(action_log, prefix="[SEPARATE]")
|
||||
|
||||
|
||||
def generate_separate_protocol(
|
||||
G: nx.DiGraph,
|
||||
@@ -93,45 +63,33 @@ def generate_separate_protocol(
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
debug_print("🌀" * 20)
|
||||
debug_print("🚀 开始生成分离协议(支持vessel字典和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 🎯 分离目的: '{purpose}'")
|
||||
debug_print(f" 📊 产物相: '{product_phase}'")
|
||||
debug_print(f" 💧 溶剂: '{solvent}'")
|
||||
debug_print(f" 📏 体积: {volume} (类型: {type(volume)})")
|
||||
debug_print(f" 🔄 重复次数: {repeats}")
|
||||
debug_print(f" 🎯 产物容器: '{product_vessel}'")
|
||||
debug_print(f" 🗑️ 废液容器: '{waste_vessel}'")
|
||||
debug_print(f" 📦 其他参数: {kwargs}")
|
||||
debug_print("🌀" * 20)
|
||||
debug_print(f"开始生成分离协议: vessel={vessel_id}, purpose={purpose}, "
|
||||
f"product_phase={product_phase}, solvent={solvent}, "
|
||||
f"volume={volume}, repeats={repeats}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 新增:记录分离前的容器状态
|
||||
debug_print("🔍 记录分离前容器状态...")
|
||||
original_liquid_volume = get_vessel_liquid_volume(vessel)
|
||||
debug_print(f"📊 分离前液体体积: {original_liquid_volume:.2f}mL")
|
||||
# 记录分离前的容器状态
|
||||
original_liquid_volume = get_resource_liquid_volume(vessel)
|
||||
debug_print(f"分离前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
# === 参数验证和标准化 ===
|
||||
debug_print("🔍 步骤1: 参数验证和标准化...")
|
||||
action_sequence.append(create_action_log(f"开始分离操作 - 容器: {vessel_id}", "🎬"))
|
||||
action_sequence.append(create_action_log(f"分离目的: {purpose}", "🧪"))
|
||||
action_sequence.append(create_action_log(f"产物相: {product_phase}", "📊"))
|
||||
action_sequence.append(action_log(f"开始分离操作 - 容器: {vessel_id}", "🎬", prefix="[SEPARATE]"))
|
||||
action_sequence.append(action_log(f"分离目的: {purpose}", "🧪", prefix="[SEPARATE]"))
|
||||
action_sequence.append(action_log(f"产物相: {product_phase}", "📊", prefix="[SEPARATE]"))
|
||||
|
||||
# 统一容器参数 - 支持字典和字符串
|
||||
def extract_vessel_id(vessel_param):
|
||||
if isinstance(vessel_param, dict):
|
||||
return vessel_param.get("id", "")
|
||||
elif isinstance(vessel_param, str):
|
||||
return vessel_param
|
||||
else:
|
||||
return ""
|
||||
final_vessel_id = vessel_id
|
||||
|
||||
final_vessel_id, _ = vessel_id
|
||||
final_to_vessel_id, _ = get_vessel(to_vessel) or get_vessel(product_vessel)
|
||||
final_waste_vessel_id, _ = get_vessel(waste_phase_to_vessel) or get_vessel(waste_vessel)
|
||||
to_vessel_result = get_vessel(to_vessel) if to_vessel else None
|
||||
if to_vessel_result is None or to_vessel_result[0] == "":
|
||||
to_vessel_result = get_vessel(product_vessel) if product_vessel else None
|
||||
final_to_vessel_id = to_vessel_result[0] if to_vessel_result else ""
|
||||
|
||||
waste_vessel_result = get_vessel(waste_phase_to_vessel) if waste_phase_to_vessel else None
|
||||
if waste_vessel_result is None or waste_vessel_result[0] == "":
|
||||
waste_vessel_result = get_vessel(waste_vessel) if waste_vessel else None
|
||||
final_waste_vessel_id = waste_vessel_result[0] if waste_vessel_result else ""
|
||||
|
||||
# 统一体积参数
|
||||
final_volume = parse_volume_input(volume or solvent_volume)
|
||||
@@ -141,16 +99,12 @@ def generate_separate_protocol(
|
||||
repeats = 1
|
||||
debug_print(f"⚠️ 重复次数参数 <= 0,自动设置为 1")
|
||||
|
||||
debug_print(f"🔧 标准化后的参数:")
|
||||
debug_print(f" 🥼 分离容器: '{final_vessel_id}'")
|
||||
debug_print(f" 🎯 产物容器: '{final_to_vessel_id}'")
|
||||
debug_print(f" 🗑️ 废液容器: '{final_waste_vessel_id}'")
|
||||
debug_print(f" 📏 溶剂体积: {final_volume}mL")
|
||||
debug_print(f" 🔄 重复次数: {repeats}")
|
||||
debug_print(f"标准化参数: vessel={final_vessel_id}, to={final_to_vessel_id}, "
|
||||
f"waste={final_waste_vessel_id}, volume={final_volume}mL, repeats={repeats}")
|
||||
|
||||
action_sequence.append(create_action_log(f"分离容器: {final_vessel_id}", "🧪"))
|
||||
action_sequence.append(create_action_log(f"溶剂体积: {final_volume}mL", "📏"))
|
||||
action_sequence.append(create_action_log(f"重复次数: {repeats}", "🔄"))
|
||||
action_sequence.append(action_log(f"分离容器: {final_vessel_id}", "🧪", prefix="[SEPARATE]"))
|
||||
action_sequence.append(action_log(f"溶剂体积: {final_volume}mL", "📏", prefix="[SEPARATE]"))
|
||||
action_sequence.append(action_log(f"重复次数: {repeats}", "🔄", prefix="[SEPARATE]"))
|
||||
|
||||
# 验证必需参数
|
||||
if not purpose:
|
||||
@@ -160,72 +114,68 @@ def generate_separate_protocol(
|
||||
if purpose not in ["wash", "extract", "separate"]:
|
||||
debug_print(f"⚠️ 未知的分离目的 '{purpose}',使用默认值 'separate'")
|
||||
purpose = "separate"
|
||||
action_sequence.append(create_action_log(f"未知目的,使用: {purpose}", "⚠️"))
|
||||
action_sequence.append(action_log(f"未知目的,使用: {purpose}", "⚠️", prefix="[SEPARATE]"))
|
||||
if product_phase not in ["top", "bottom"]:
|
||||
debug_print(f"⚠️ 未知的产物相 '{product_phase}',使用默认值 'top'")
|
||||
product_phase = "top"
|
||||
action_sequence.append(create_action_log(f"未知相别,使用: {product_phase}", "⚠️"))
|
||||
action_sequence.append(action_log(f"未知相别,使用: {product_phase}", "⚠️", prefix="[SEPARATE]"))
|
||||
|
||||
debug_print("✅ 参数验证通过")
|
||||
action_sequence.append(create_action_log("参数验证通过", "✅"))
|
||||
action_sequence.append(action_log("参数验证通过", "✅", prefix="[SEPARATE]"))
|
||||
|
||||
# === 查找设备 ===
|
||||
debug_print("🔍 步骤2: 查找设备...")
|
||||
action_sequence.append(create_action_log("正在查找相关设备...", "🔍"))
|
||||
action_sequence.append(action_log("正在查找相关设备...", "🔍", prefix="[SEPARATE]"))
|
||||
|
||||
# 查找分离器设备
|
||||
separator_device = find_separator_device(G, final_vessel_id) # 🔧 使用 final_vessel_id
|
||||
separator_device = find_separator_device(G, final_vessel_id)
|
||||
if separator_device:
|
||||
action_sequence.append(create_action_log(f"找到分离器设备: {separator_device}", "🧪"))
|
||||
action_sequence.append(action_log(f"找到分离器设备: {separator_device}", "🧪", prefix="[SEPARATE]"))
|
||||
else:
|
||||
debug_print("⚠️ 未找到分离器设备,可能无法执行分离")
|
||||
action_sequence.append(create_action_log("未找到分离器设备", "⚠️"))
|
||||
action_sequence.append(action_log("未找到分离器设备", "⚠️", prefix="[SEPARATE]"))
|
||||
|
||||
# 查找搅拌器
|
||||
stirrer_device = find_connected_stirrer(G, final_vessel_id) # 🔧 使用 final_vessel_id
|
||||
stirrer_device = find_connected_stirrer(G, final_vessel_id)
|
||||
if stirrer_device:
|
||||
action_sequence.append(create_action_log(f"找到搅拌器: {stirrer_device}", "🌪️"))
|
||||
action_sequence.append(action_log(f"找到搅拌器: {stirrer_device}", "🌪️", prefix="[SEPARATE]"))
|
||||
else:
|
||||
action_sequence.append(create_action_log("未找到搅拌器", "⚠️"))
|
||||
action_sequence.append(action_log("未找到搅拌器", "⚠️", prefix="[SEPARATE]"))
|
||||
|
||||
# 查找溶剂容器(如果需要)
|
||||
solvent_vessel = ""
|
||||
if solvent and solvent.strip():
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
try:
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
except ValueError:
|
||||
solvent_vessel = ""
|
||||
if solvent_vessel:
|
||||
action_sequence.append(create_action_log(f"找到溶剂容器: {solvent_vessel}", "💧"))
|
||||
action_sequence.append(action_log(f"找到溶剂容器: {solvent_vessel}", "💧", prefix="[SEPARATE]"))
|
||||
else:
|
||||
action_sequence.append(create_action_log(f"未找到溶剂容器: {solvent}", "⚠️"))
|
||||
action_sequence.append(action_log(f"未找到溶剂容器: {solvent}", "⚠️", prefix="[SEPARATE]"))
|
||||
|
||||
debug_print(f"📊 设备配置:")
|
||||
debug_print(f" 🧪 分离器设备: '{separator_device}'")
|
||||
debug_print(f" 🌪️ 搅拌器设备: '{stirrer_device}'")
|
||||
debug_print(f" 💧 溶剂容器: '{solvent_vessel}'")
|
||||
debug_print(f"设备配置: separator={separator_device}, stirrer={stirrer_device}, solvent_vessel={solvent_vessel}")
|
||||
|
||||
# === 执行分离流程 ===
|
||||
debug_print("🔍 步骤3: 执行分离流程...")
|
||||
action_sequence.append(create_action_log("开始分离工作流程", "🎯"))
|
||||
action_sequence.append(action_log("开始分离工作流程", "🎯", prefix="[SEPARATE]"))
|
||||
|
||||
# 🔧 新增:体积变化跟踪变量
|
||||
# 体积变化跟踪变量
|
||||
current_volume = original_liquid_volume
|
||||
|
||||
try:
|
||||
for repeat_idx in range(repeats):
|
||||
cycle_num = repeat_idx + 1
|
||||
debug_print(f"🔄 第{cycle_num}轮: 开始分离循环 {cycle_num}/{repeats}")
|
||||
action_sequence.append(create_action_log(f"分离循环 {cycle_num}/{repeats} 开始", "🔄"))
|
||||
debug_print(f"分离循环 {cycle_num}/{repeats} 开始")
|
||||
action_sequence.append(action_log(f"分离循环 {cycle_num}/{repeats} 开始", "🔄", prefix="[SEPARATE]"))
|
||||
|
||||
# 步骤3.1: 添加溶剂(如果需要)
|
||||
if solvent_vessel and final_volume > 0:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤1: 添加溶剂 {solvent} ({final_volume}mL)")
|
||||
action_sequence.append(create_action_log(f"向分离容器添加 {final_volume}mL {solvent}", "💧"))
|
||||
action_sequence.append(action_log(f"向分离容器添加 {final_volume}mL {solvent}", "💧", prefix="[SEPARATE]"))
|
||||
|
||||
try:
|
||||
# 使用pump protocol添加溶剂
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent_vessel,
|
||||
to_vessel=final_vessel_id, # 🔧 使用 final_vessel_id
|
||||
to_vessel=final_vessel_id,
|
||||
volume=final_volume,
|
||||
amount="",
|
||||
time=0.0,
|
||||
@@ -242,30 +192,27 @@ def generate_separate_protocol(
|
||||
**kwargs
|
||||
)
|
||||
action_sequence.extend(pump_actions)
|
||||
debug_print(f"✅ 溶剂添加完成,添加了 {len(pump_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"溶剂转移完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
action_sequence.append(action_log(f"溶剂转移完成 ({len(pump_actions)} 个操作)", "✅", prefix="[SEPARATE]"))
|
||||
|
||||
# 🔧 新增:更新体积 - 添加溶剂后
|
||||
# 更新体积 - 添加溶剂后
|
||||
current_volume += final_volume
|
||||
update_vessel_volume(vessel, G, current_volume, f"添加{final_volume}mL {solvent}后")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 溶剂添加失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"溶剂添加失败: {str(e)}", "❌"))
|
||||
action_sequence.append(action_log(f"溶剂添加失败: {str(e)}", "❌", prefix="[SEPARATE]"))
|
||||
else:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤1: 无需添加溶剂")
|
||||
action_sequence.append(create_action_log("无需添加溶剂", "⏭️"))
|
||||
action_sequence.append(action_log("无需添加溶剂", "⏭️", prefix="[SEPARATE]"))
|
||||
|
||||
# 步骤3.2: 启动搅拌(如果有搅拌器)
|
||||
if stirrer_device and stir_time > 0:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤2: 开始搅拌 ({stir_speed}rpm,持续 {stir_time}s)")
|
||||
action_sequence.append(create_action_log(f"开始搅拌: {stir_speed}rpm,持续 {stir_time}s", "🌪️"))
|
||||
action_sequence.append(action_log(f"开始搅拌: {stir_speed}rpm,持续 {stir_time}s", "🌪️", prefix="[SEPARATE]"))
|
||||
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_device,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": final_vessel_id}, # 🔧 使用 final_vessel_id
|
||||
"vessel": {"id": final_vessel_id},
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": f"分离混合 - {purpose}"
|
||||
}
|
||||
@@ -273,43 +220,37 @@ def generate_separate_protocol(
|
||||
|
||||
# 搅拌等待
|
||||
stir_minutes = stir_time / 60
|
||||
action_sequence.append(create_action_log(f"搅拌中,持续 {stir_minutes:.1f} 分钟", "⏱️"))
|
||||
action_sequence.append(action_log(f"搅拌中,持续 {stir_minutes:.1f} 分钟", "⏱️", prefix="[SEPARATE]"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": stir_time}
|
||||
})
|
||||
|
||||
# 停止搅拌
|
||||
action_sequence.append(create_action_log("停止搅拌器", "🛑"))
|
||||
action_sequence.append(action_log("停止搅拌器", "🛑", prefix="[SEPARATE]"))
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_device,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {"vessel": final_vessel_id} # 🔧 使用 final_vessel_id
|
||||
"action_kwargs": {"vessel": final_vessel_id}
|
||||
})
|
||||
|
||||
else:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤2: 无需搅拌")
|
||||
action_sequence.append(create_action_log("无需搅拌", "⏭️"))
|
||||
action_sequence.append(action_log("无需搅拌", "⏭️", prefix="[SEPARATE]"))
|
||||
|
||||
# 步骤3.3: 静置分层
|
||||
if settling_time > 0:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤3: 静置分层 ({settling_time}s)")
|
||||
settling_minutes = settling_time / 60
|
||||
action_sequence.append(create_action_log(f"静置分层 ({settling_minutes:.1f} 分钟)", "⚖️"))
|
||||
action_sequence.append(action_log(f"静置分层 ({settling_minutes:.1f} 分钟)", "⚖️", prefix="[SEPARATE]"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": settling_time}
|
||||
})
|
||||
else:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤3: 未指定静置时间")
|
||||
action_sequence.append(create_action_log("未指定静置时间", "⏭️"))
|
||||
action_sequence.append(action_log("未指定静置时间", "⏭️", prefix="[SEPARATE]"))
|
||||
|
||||
# 步骤3.4: 执行分离操作
|
||||
if separator_device:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤4: 执行分离操作")
|
||||
action_sequence.append(create_action_log(f"执行分离: 收集{product_phase}相", "🧪"))
|
||||
|
||||
# 🔧 替换为具体的分离操作逻辑(基于old版本)
|
||||
action_sequence.append(action_log(f"执行分离: 收集{product_phase}相", "🧪", prefix="[SEPARATE]"))
|
||||
|
||||
# 首先进行分液判断(电导突跃)
|
||||
action_sequence.append({
|
||||
@@ -324,11 +265,10 @@ def generate_separate_protocol(
|
||||
phase_volume = current_volume / 2
|
||||
|
||||
# 智能查找分离容器底部
|
||||
separation_vessel_bottom = find_separation_vessel_bottom(G, final_vessel_id) # ✅
|
||||
separation_vessel_bottom = find_separation_vessel_bottom(G, final_vessel_id)
|
||||
|
||||
if product_phase == "bottom":
|
||||
debug_print(f"🔄 收集底相产物到 {final_to_vessel_id}")
|
||||
action_sequence.append(create_action_log("收集底相产物", "📦"))
|
||||
action_sequence.append(action_log("收集底相产物", "📦", prefix="[SEPARATE]"))
|
||||
|
||||
# 产物转移到目标瓶
|
||||
if final_to_vessel_id:
|
||||
@@ -364,8 +304,7 @@ def generate_separate_protocol(
|
||||
action_sequence.extend(pump_actions)
|
||||
|
||||
elif product_phase == "top":
|
||||
debug_print(f"🔄 收集上相产物到 {final_to_vessel_id}")
|
||||
action_sequence.append(create_action_log("收集上相产物", "📦"))
|
||||
action_sequence.append(action_log("收集上相产物", "📦", prefix="[SEPARATE]"))
|
||||
|
||||
# 弃去下面那一相进废液
|
||||
if final_waste_vessel_id:
|
||||
@@ -400,10 +339,9 @@ def generate_separate_protocol(
|
||||
)
|
||||
action_sequence.extend(pump_actions)
|
||||
|
||||
debug_print(f"✅ 分离操作已完成")
|
||||
action_sequence.append(create_action_log("分离操作完成", "✅"))
|
||||
action_sequence.append(action_log("分离操作完成", "✅", prefix="[SEPARATE]"))
|
||||
|
||||
# 🔧 新增:分离后体积估算
|
||||
# 分离后体积估算
|
||||
separated_volume = phase_volume * 0.95 # 假设5%损失,只保留产物相体积
|
||||
update_vessel_volume(vessel, G, separated_volume, f"分离操作后(第{cycle_num}轮)")
|
||||
current_volume = separated_volume
|
||||
@@ -411,23 +349,21 @@ def generate_separate_protocol(
|
||||
# 收集结果
|
||||
if final_to_vessel_id:
|
||||
action_sequence.append(
|
||||
create_action_log(f"产物 ({product_phase}相) 收集到: {final_to_vessel_id}", "📦"))
|
||||
action_log(f"产物 ({product_phase}相) 收集到: {final_to_vessel_id}", "📦", prefix="[SEPARATE]"))
|
||||
if final_waste_vessel_id:
|
||||
action_sequence.append(create_action_log(f"废相收集到: {final_waste_vessel_id}", "🗑️"))
|
||||
action_sequence.append(action_log(f"废相收集到: {final_waste_vessel_id}", "🗑️", prefix="[SEPARATE]"))
|
||||
|
||||
else:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤4: 无分离器设备,跳过分离")
|
||||
action_sequence.append(create_action_log("无分离器设备可用", "❌"))
|
||||
action_sequence.append(action_log("无分离器设备可用", "❌", prefix="[SEPARATE]"))
|
||||
# 添加等待时间模拟分离
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10.0}
|
||||
})
|
||||
|
||||
# 🔧 新增:如果不是最后一次,从中转瓶转移回分液漏斗(基于old版本逻辑)
|
||||
# 如果不是最后一次,从中转瓶转移回分液漏斗
|
||||
if repeat_idx < repeats - 1 and final_to_vessel_id and final_to_vessel_id != final_vessel_id:
|
||||
debug_print(f"🔄 第{cycle_num}轮: 产物转移回分离容器准备下一轮")
|
||||
action_sequence.append(create_action_log("产物转回分离容器,准备下一轮", "🔄"))
|
||||
action_sequence.append(action_log("产物转回分离容器,准备下一轮", "🔄", prefix="[SEPARATE]"))
|
||||
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
@@ -444,368 +380,85 @@ def generate_separate_protocol(
|
||||
|
||||
# 循环间等待(除了最后一次)
|
||||
if repeat_idx < repeats - 1:
|
||||
debug_print(f"🔄 第{cycle_num}轮: 等待下一次循环...")
|
||||
action_sequence.append(create_action_log("等待下一次循环...", "⏳"))
|
||||
action_sequence.append(action_log("等待下一次循环...", "⏳", prefix="[SEPARATE]"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5}
|
||||
})
|
||||
else:
|
||||
action_sequence.append(create_action_log(f"分离循环 {cycle_num}/{repeats} 完成", "🌟"))
|
||||
action_sequence.append(action_log(f"分离循环 {cycle_num}/{repeats} 完成", "🌟", prefix="[SEPARATE]"))
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 分离工作流程执行失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"分离工作流程失败: {str(e)}", "❌"))
|
||||
action_sequence.append(action_log(f"分离工作流程失败: {str(e)}", "❌", prefix="[SEPARATE]"))
|
||||
|
||||
# 🔧 新增:分离完成后的最终状态报告
|
||||
final_liquid_volume = get_vessel_liquid_volume(vessel)
|
||||
# 分离完成后的最终状态报告
|
||||
final_liquid_volume = get_resource_liquid_volume(vessel)
|
||||
|
||||
# === 最终结果 ===
|
||||
total_time = (stir_time + settling_time + 15) * repeats # 估算总时间
|
||||
|
||||
debug_print("🌀" * 20)
|
||||
debug_print(f"🎉 分离协议生成完成")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)}")
|
||||
debug_print(f" ⏱️ 预计总时间: {total_time:.0f}s ({total_time / 60:.1f} 分钟)")
|
||||
debug_print(f" 🥼 分离容器: {final_vessel_id}")
|
||||
debug_print(f" 🎯 分离目的: {purpose}")
|
||||
debug_print(f" 📊 产物相: {product_phase}")
|
||||
debug_print(f" 🔄 重复次数: {repeats}")
|
||||
debug_print(f"💧 体积变化统计:")
|
||||
debug_print(f" - 分离前体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 分离后体积: {final_liquid_volume:.2f}mL")
|
||||
if solvent:
|
||||
debug_print(f" 💧 溶剂: {solvent} ({final_volume}mL × {repeats}轮 = {final_volume * repeats:.2f}mL)")
|
||||
if final_to_vessel_id:
|
||||
debug_print(f" 🎯 产物容器: {final_to_vessel_id}")
|
||||
if final_waste_vessel_id:
|
||||
debug_print(f" 🗑️ 废液容器: {final_waste_vessel_id}")
|
||||
debug_print("🌀" * 20)
|
||||
debug_print(f"分离协议生成完成: {len(action_sequence)} 个动作, "
|
||||
f"预计 {total_time:.0f}s, 体积 {original_liquid_volume:.2f}→{final_liquid_volume:.2f}mL")
|
||||
|
||||
# 添加完成日志
|
||||
summary_msg = f"分离协议完成: {final_vessel_id} ({purpose},{repeats} 次循环)"
|
||||
if solvent:
|
||||
summary_msg += f",使用 {final_volume * repeats:.2f}mL {solvent}"
|
||||
action_sequence.append(create_action_log(summary_msg, "🎉"))
|
||||
action_sequence.append(action_log(summary_msg, "🎉", prefix="[SEPARATE]"))
|
||||
|
||||
return action_sequence
|
||||
|
||||
def parse_volume_input(volume_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析体积输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
volume_input: 体积输入(如 "200 mL", "?", 50.0)
|
||||
|
||||
Returns:
|
||||
float: 体积(毫升)
|
||||
"""
|
||||
if isinstance(volume_input, (int, float)):
|
||||
debug_print(f"📏 体积输入为数值: {volume_input}")
|
||||
return float(volume_input)
|
||||
|
||||
if not volume_input or not str(volume_input).strip():
|
||||
debug_print(f"⚠️ 体积输入为空,返回 0.0mL")
|
||||
return 0.0
|
||||
|
||||
volume_str = str(volume_input).lower().strip()
|
||||
debug_print(f"🔍 解析体积输入: '{volume_str}'")
|
||||
|
||||
# 处理未知体积
|
||||
if volume_str in ['?', 'unknown', 'tbd', 'to be determined', '未知', '待定']:
|
||||
default_volume = 100.0 # 默认100mL
|
||||
debug_print(f"❓ 检测到未知体积,使用默认值: {default_volume}mL")
|
||||
return default_volume
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
volume_clean = re.sub(r'\s+', '', volume_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(ml|l|μl|ul|microliter|milliliter|liter|毫升|升|微升)?', volume_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"⚠️ 无法解析体积: '{volume_str}',使用默认值 100mL")
|
||||
return 100.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 'ml' # 默认单位为毫升
|
||||
|
||||
# 转换为毫升
|
||||
if unit in ['l', 'liter', '升']:
|
||||
volume = value * 1000.0 # L -> mL
|
||||
debug_print(f"🔄 体积转换: {value}L -> {volume}mL")
|
||||
elif unit in ['μl', 'ul', 'microliter', '微升']:
|
||||
volume = value / 1000.0 # μL -> mL
|
||||
debug_print(f"🔄 体积转换: {value}μL -> {volume}mL")
|
||||
else: # ml, milliliter, 毫升 或默认
|
||||
volume = value # 已经是mL
|
||||
debug_print(f"✅ 体积已为毫升单位: {volume}mL")
|
||||
|
||||
return volume
|
||||
|
||||
def find_solvent_vessel(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""查找溶剂容器,支持多种匹配模式"""
|
||||
if not solvent or not solvent.strip():
|
||||
debug_print("⏭️ 未指定溶剂,跳过溶剂容器查找")
|
||||
return ""
|
||||
|
||||
debug_print(f"🔍 正在查找溶剂 '{solvent}' 的容器...")
|
||||
|
||||
# 🔧 方法1:直接搜索 data.reagent_name 和 config.reagent
|
||||
debug_print(f"📋 方法1: 搜索试剂字段...")
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node].get('data', {})
|
||||
node_type = G.nodes[node].get('type', '')
|
||||
config_data = G.nodes[node].get('config', {})
|
||||
|
||||
# 只搜索容器类型的节点
|
||||
if node_type == 'container':
|
||||
reagent_name = node_data.get('reagent_name', '').lower()
|
||||
config_reagent = config_data.get('reagent', '').lower()
|
||||
|
||||
# 精确匹配
|
||||
if reagent_name == solvent.lower() or config_reagent == solvent.lower():
|
||||
debug_print(f"✅ 通过试剂字段精确匹配找到容器: {node}")
|
||||
return node
|
||||
|
||||
# 模糊匹配
|
||||
if (solvent.lower() in reagent_name and reagent_name) or \
|
||||
(solvent.lower() in config_reagent and config_reagent):
|
||||
debug_print(f"✅ 通过试剂字段模糊匹配找到容器: {node}")
|
||||
return node
|
||||
|
||||
# 🔧 方法2:常见的容器命名规则
|
||||
debug_print(f"📋 方法2: 使用命名规则...")
|
||||
solvent_clean = solvent.lower().replace(' ', '_').replace('-', '_')
|
||||
possible_names = [
|
||||
f"flask_{solvent_clean}",
|
||||
f"bottle_{solvent_clean}",
|
||||
f"vessel_{solvent_clean}",
|
||||
f"{solvent_clean}_flask",
|
||||
f"{solvent_clean}_bottle",
|
||||
f"solvent_{solvent_clean}",
|
||||
f"reagent_{solvent_clean}",
|
||||
f"reagent_bottle_{solvent_clean}",
|
||||
f"reagent_bottle_1", # 通用试剂瓶
|
||||
f"reagent_bottle_2",
|
||||
f"reagent_bottle_3"
|
||||
]
|
||||
|
||||
debug_print(f"🎯 尝试的容器名称: {possible_names[:5]}... (共 {len(possible_names)} 个)")
|
||||
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
node_type = G.nodes[name].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"✅ 通过命名规则找到容器: {name}")
|
||||
return name
|
||||
|
||||
# 🔧 方法3:使用第一个试剂瓶作为备选
|
||||
debug_print(f"📋 方法3: 查找备用试剂瓶...")
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
if (node_data.get('type') == 'container' and
|
||||
('reagent' in node_id.lower() or 'bottle' in node_id.lower())):
|
||||
debug_print(f"⚠️ 未找到专用容器,使用备用容器: {node_id}")
|
||||
return node_id
|
||||
|
||||
debug_print(f"❌ 无法找到溶剂 '{solvent}' 的容器")
|
||||
return ""
|
||||
|
||||
def find_separator_device(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找分离器设备,支持多种查找方式"""
|
||||
debug_print(f"🔍 正在查找容器 '{vessel}' 的分离器设备...")
|
||||
|
||||
# 方法1:查找连接到容器的分离器设备
|
||||
debug_print(f"📋 方法1: 检查连接的分离器...")
|
||||
separator_nodes = []
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '').lower()
|
||||
if 'separator' in node_class:
|
||||
separator_nodes.append(node)
|
||||
debug_print(f"📋 发现分离器设备: {node}")
|
||||
|
||||
# 检查是否连接到目标容器
|
||||
if G.has_edge(node, vessel) or G.has_edge(vessel, node):
|
||||
debug_print(f"✅ 找到连接的分离器: {node}")
|
||||
return node
|
||||
|
||||
debug_print(f"📊 找到的分离器总数: {len(separator_nodes)}")
|
||||
|
||||
|
||||
# 方法2:根据命名规则查找
|
||||
debug_print(f"📋 方法2: 使用命名规则...")
|
||||
possible_names = [
|
||||
f"{vessel}_controller",
|
||||
f"{vessel}_separator",
|
||||
vessel, # 容器本身可能就是分离器
|
||||
"separator_1",
|
||||
"virtual_separator",
|
||||
"liquid_handler_1", # 液体处理器也可能用于分离
|
||||
"liquid_handler_1",
|
||||
"controller_1"
|
||||
]
|
||||
|
||||
debug_print(f"🎯 尝试的分离器名称: {possible_names}")
|
||||
|
||||
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
node_class = G.nodes[name].get('class', '').lower()
|
||||
if 'separator' in node_class or 'controller' in node_class:
|
||||
debug_print(f"✅ 通过命名规则找到分离器: {name}")
|
||||
return name
|
||||
|
||||
# 方法3:查找第一个分离器设备
|
||||
debug_print(f"📋 方法3: 使用第一个可用分离器...")
|
||||
|
||||
# 方法3:使用第一个可用分离器
|
||||
if separator_nodes:
|
||||
debug_print(f"⚠️ 使用第一个分离器设备: {separator_nodes[0]}")
|
||||
return separator_nodes[0]
|
||||
|
||||
|
||||
debug_print(f"❌ 未找到分离器设备")
|
||||
return ""
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找连接到指定容器的搅拌器"""
|
||||
debug_print(f"🔍 正在查找与容器 {vessel} 连接的搅拌器...")
|
||||
|
||||
stirrer_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if 'stirrer' in node_class.lower():
|
||||
stirrer_nodes.append(node)
|
||||
debug_print(f"📋 发现搅拌器: {node}")
|
||||
|
||||
debug_print(f"📊 找到的搅拌器总数: {len(stirrer_nodes)}")
|
||||
|
||||
# 检查哪个搅拌器与目标容器相连
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 找到连接的搅拌器: {stirrer}")
|
||||
return stirrer
|
||||
|
||||
# 如果没有连接的搅拌器,返回第一个可用的
|
||||
if stirrer_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的搅拌器,使用第一个可用的: {stirrer_nodes[0]}")
|
||||
return stirrer_nodes[0]
|
||||
|
||||
debug_print("❌ 未找到搅拌器")
|
||||
return ""
|
||||
|
||||
def get_vessel_liquid_volume(vessel: dict) -> float:
|
||||
"""
|
||||
获取容器中的液体体积 - 支持vessel字典
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
|
||||
Returns:
|
||||
float: 液体体积(mL)
|
||||
"""
|
||||
if not vessel or "data" not in vessel:
|
||||
debug_print(f"⚠️ 容器数据为空,返回 0.0mL")
|
||||
return 0.0
|
||||
|
||||
vessel_data = vessel["data"]
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
debug_print(f"🔍 读取容器 '{vessel_id}' 体积数据: {vessel_data}")
|
||||
|
||||
# 检查liquid_volume字段
|
||||
if "liquid_volume" in vessel_data:
|
||||
liquid_volume = vessel_data["liquid_volume"]
|
||||
|
||||
# 处理列表格式
|
||||
if isinstance(liquid_volume, list):
|
||||
if len(liquid_volume) > 0:
|
||||
volume = liquid_volume[0]
|
||||
if isinstance(volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (列表格式)")
|
||||
return float(volume)
|
||||
|
||||
# 处理直接数值格式
|
||||
elif isinstance(liquid_volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {liquid_volume}mL (数值格式)")
|
||||
return float(liquid_volume)
|
||||
|
||||
# 检查其他可能的体积字段
|
||||
volume_keys = ['current_volume', 'total_volume', 'volume']
|
||||
for key in volume_keys:
|
||||
if key in vessel_data:
|
||||
try:
|
||||
volume = float(vessel_data[key])
|
||||
if volume > 0:
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (字段: {key})")
|
||||
return volume
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
debug_print(f"⚠️ 无法获取容器 '{vessel_id}' 的体积,返回默认值 50.0mL")
|
||||
return 50.0
|
||||
|
||||
def update_vessel_volume(vessel: dict, G: nx.DiGraph, new_volume: float, description: str = "") -> None:
|
||||
"""
|
||||
更新容器体积(同时更新vessel字典和图节点)
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
G: 网络图
|
||||
new_volume: 新体积
|
||||
description: 更新描述
|
||||
"""
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
if description:
|
||||
debug_print(f"🔧 更新容器体积 - {description}")
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
if "data" in vessel:
|
||||
if "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [new_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"] = {"liquid_volume": new_volume}
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
debug_print(f"📊 容器 '{vessel_id}' 体积已更新为: {new_volume:.2f}mL")
|
||||
|
||||
|
||||
def find_separation_vessel_bottom(G: nx.DiGraph, vessel_id: str) -> str:
|
||||
"""
|
||||
智能查找分离容器的底部容器(假设为flask或vessel类型)
|
||||
|
||||
|
||||
Args:
|
||||
G: 网络图
|
||||
vessel_id: 分离容器ID
|
||||
|
||||
|
||||
Returns:
|
||||
str: 底部容器ID
|
||||
"""
|
||||
debug_print(f"🔍 查找分离容器 {vessel_id} 的底部容器...")
|
||||
|
||||
# 方法1:根据命名规则推测
|
||||
possible_bottoms = [
|
||||
f"{vessel_id}_bottom",
|
||||
@@ -814,32 +467,25 @@ def find_separation_vessel_bottom(G: nx.DiGraph, vessel_id: str) -> str:
|
||||
f"{vessel_id}_flask",
|
||||
f"{vessel_id}_vessel"
|
||||
]
|
||||
|
||||
debug_print(f"📋 尝试的底部容器名称: {possible_bottoms}")
|
||||
|
||||
|
||||
for bottom_id in possible_bottoms:
|
||||
if bottom_id in G.nodes():
|
||||
node_type = G.nodes[bottom_id].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"✅ 通过命名规则找到底部容器: {bottom_id}")
|
||||
return bottom_id
|
||||
|
||||
# 方法2:查找与分离器相连的容器(假设底部容器会与分离器相连)
|
||||
debug_print(f"📋 方法2: 查找连接的容器...")
|
||||
|
||||
# 方法2:查找与分离器相连的容器
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if 'separator' in node_class.lower():
|
||||
# 检查分离器的输入端
|
||||
if G.has_edge(node, vessel_id):
|
||||
for neighbor in G.neighbors(node):
|
||||
if neighbor != vessel_id:
|
||||
neighbor_type = G.nodes[neighbor].get('type', '')
|
||||
if neighbor_type == 'container':
|
||||
debug_print(f"✅ 通过连接找到底部容器: {neighbor}")
|
||||
return neighbor
|
||||
|
||||
|
||||
debug_print(f"❌ 无法找到分离容器 {vessel_id} 的底部容器")
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,116 +1,40 @@
|
||||
from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .utils.unit_parser import parse_time_input
|
||||
from .utils.resource_helper import get_resource_id, get_resource_display_info
|
||||
from .utils.logger_util import debug_print
|
||||
from .utils.vessel_parser import find_connected_stirrer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[STIR] {message}")
|
||||
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str = None) -> str:
|
||||
"""查找与指定容器相连的搅拌设备"""
|
||||
debug_print(f"🔍 查找搅拌设备,目标容器: {vessel} 🥽")
|
||||
|
||||
# 🔧 查找所有搅拌设备
|
||||
stirrer_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if 'stirrer' in node_class.lower() or 'virtual_stirrer' in node_class:
|
||||
stirrer_nodes.append(node)
|
||||
debug_print(f"🎉 找到搅拌设备: {node} 🌪️")
|
||||
|
||||
# 🔗 检查连接
|
||||
if vessel and stirrer_nodes:
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 搅拌设备 '{stirrer}' 与容器 '{vessel}' 相连 🔗")
|
||||
return stirrer
|
||||
|
||||
# 🎯 使用第一个可用设备
|
||||
if stirrer_nodes:
|
||||
selected = stirrer_nodes[0]
|
||||
debug_print(f"🔧 使用第一个搅拌设备: {selected} 🌪️")
|
||||
return selected
|
||||
|
||||
# 🆘 默认设备
|
||||
debug_print("⚠️ 未找到搅拌设备,使用默认设备 🌪️")
|
||||
return "stirrer_1"
|
||||
|
||||
def validate_and_fix_params(stir_time: float, stir_speed: float, settling_time: float) -> tuple:
|
||||
"""验证和修正参数"""
|
||||
# ⏰ 搅拌时间验证
|
||||
if stir_time < 0:
|
||||
debug_print(f"⚠️ 搅拌时间 {stir_time}s 无效,修正为 100s 🕐")
|
||||
debug_print(f"搅拌时间 {stir_time}s 无效,修正为 100s")
|
||||
stir_time = 100.0
|
||||
elif stir_time > 100: # 限制为100s
|
||||
debug_print(f"⚠️ 搅拌时间 {stir_time}s 过长,仿真运行时,修正为 100s 🕐")
|
||||
debug_print(f"搅拌时间 {stir_time}s 过长,仿真运行时修正为 100s")
|
||||
stir_time = 100.0
|
||||
else:
|
||||
debug_print(f"✅ 搅拌时间 {stir_time}s ({stir_time/60:.1f}分钟) 有效 ⏰")
|
||||
|
||||
# 🌪️ 搅拌速度验证
|
||||
|
||||
if stir_speed < 10.0 or stir_speed > 1500.0:
|
||||
debug_print(f"⚠️ 搅拌速度 {stir_speed} RPM 超出范围,修正为 300 RPM 🌪️")
|
||||
debug_print(f"搅拌速度 {stir_speed} RPM 超出范围,修正为 300 RPM")
|
||||
stir_speed = 300.0
|
||||
else:
|
||||
debug_print(f"✅ 搅拌速度 {stir_speed} RPM 在正常范围内 🌪️")
|
||||
|
||||
# ⏱️ 沉降时间验证
|
||||
|
||||
if settling_time < 0 or settling_time > 600: # 限制为10分钟
|
||||
debug_print(f"⚠️ 沉降时间 {settling_time}s 超出范围,修正为 60s ⏱️")
|
||||
debug_print(f"沉降时间 {settling_time}s 超出范围,修正为 60s")
|
||||
settling_time = 60.0
|
||||
else:
|
||||
debug_print(f"✅ 沉降时间 {settling_time}s 在正常范围内 ⏱️")
|
||||
|
||||
|
||||
return stir_time, stir_speed, settling_time
|
||||
|
||||
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||
"""
|
||||
从vessel参数中提取vessel_id
|
||||
|
||||
Args:
|
||||
vessel: vessel字典或vessel_id字符串
|
||||
|
||||
Returns:
|
||||
str: vessel_id
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
vessel_id = list(vessel.values())[0].get("id", "")
|
||||
debug_print(f"🔧 从vessel字典提取ID: {vessel_id}")
|
||||
return vessel_id
|
||||
elif isinstance(vessel, str):
|
||||
debug_print(f"🔧 vessel参数为字符串: {vessel}")
|
||||
return vessel
|
||||
else:
|
||||
debug_print(f"⚠️ 无效的vessel参数类型: {type(vessel)}")
|
||||
return ""
|
||||
def extract_vessel_id(vessel) -> str:
|
||||
"""从vessel参数中提取vessel_id,兼容 str / dict / ResourceDictInstance"""
|
||||
return get_resource_id(vessel)
|
||||
|
||||
def get_vessel_display_info(vessel: Union[str, dict]) -> str:
|
||||
"""
|
||||
获取容器的显示信息(用于日志)
|
||||
|
||||
Args:
|
||||
vessel: vessel字典或vessel_id字符串
|
||||
|
||||
Returns:
|
||||
str: 显示信息
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
vessel_name = vessel.get("name", "")
|
||||
if vessel_name:
|
||||
return f"{vessel_id} ({vessel_name})"
|
||||
else:
|
||||
return vessel_id
|
||||
else:
|
||||
return str(vessel)
|
||||
def get_vessel_display_info(vessel) -> str:
|
||||
"""获取容器的显示信息(用于日志),兼容 str / dict / ResourceDictInstance"""
|
||||
return get_resource_display_info(vessel)
|
||||
|
||||
def generate_stir_protocol(
|
||||
G: nx.DiGraph,
|
||||
@@ -125,16 +49,13 @@ def generate_stir_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成搅拌操作的协议序列 - 修复vessel参数传递"""
|
||||
|
||||
# 🔧 核心修改:正确处理vessel参数
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
|
||||
# 🔧 关键修复:确保vessel_resource是完整的Resource对象
|
||||
|
||||
# 确保vessel_resource是完整的Resource对象
|
||||
if isinstance(vessel, dict):
|
||||
vessel_resource = vessel # 已经是完整的Resource字典
|
||||
debug_print(f"✅ 使用传入的vessel Resource对象")
|
||||
vessel_resource = vessel
|
||||
else:
|
||||
# 如果只是字符串,构建一个基本的Resource对象
|
||||
vessel_resource = {
|
||||
"id": vessel,
|
||||
"name": "",
|
||||
@@ -150,91 +71,60 @@ def generate_stir_protocol(
|
||||
"sample_id": "",
|
||||
"type": ""
|
||||
}
|
||||
debug_print(f"🔧 构建了基本的vessel Resource对象: {vessel}")
|
||||
|
||||
debug_print("🌪️" * 20)
|
||||
debug_print("🚀 开始生成搅拌协议(支持vessel字典)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel_display} (ID: {vessel_id})")
|
||||
debug_print(f" ⏰ time: {time}")
|
||||
debug_print(f" 🕐 stir_time: {stir_time}")
|
||||
debug_print(f" 🎯 time_spec: {time_spec}")
|
||||
debug_print(f" 🌪️ stir_speed: {stir_speed} RPM")
|
||||
debug_print(f" ⏱️ settling_time: {settling_time}")
|
||||
debug_print("🌪️" * 20)
|
||||
|
||||
# 📋 参数验证
|
||||
debug_print("📍 步骤1: 参数验证... 🔧")
|
||||
if not vessel_id: # 🔧 使用 vessel_id
|
||||
debug_print("❌ vessel 参数不能为空! 😱")
|
||||
|
||||
# 参数验证
|
||||
if not vessel_id:
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print(f"❌ 容器 '{vessel_id}' 不存在于系统中! 😞")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print("✅ 基础参数验证通过 🎯")
|
||||
|
||||
# 🔄 参数解析
|
||||
debug_print("📍 步骤2: 参数解析... ⚡")
|
||||
|
||||
# 确定实际时间(优先级:time_spec > stir_time > time)
|
||||
# 参数解析 — 确定实际时间(优先级:time_spec > stir_time > time)
|
||||
if time_spec:
|
||||
parsed_time = parse_time_input(time_spec)
|
||||
debug_print(f"🎯 使用time_spec: '{time_spec}' → {parsed_time}s")
|
||||
elif stir_time not in ["0", 0, 0.0]:
|
||||
parsed_time = parse_time_input(stir_time)
|
||||
debug_print(f"🎯 使用stir_time: {stir_time} → {parsed_time}s")
|
||||
else:
|
||||
parsed_time = parse_time_input(time)
|
||||
debug_print(f"🎯 使用time: {time} → {parsed_time}s")
|
||||
|
||||
# 解析沉降时间
|
||||
parsed_settling_time = parse_time_input(settling_time)
|
||||
|
||||
# 🕐 模拟运行时间优化
|
||||
debug_print(" ⏱️ 检查模拟运行时间限制...")
|
||||
# 模拟运行时间优化
|
||||
original_stir_time = parsed_time
|
||||
original_settling_time = parsed_settling_time
|
||||
|
||||
|
||||
# 搅拌时间限制为60秒
|
||||
stir_time_limit = 60.0
|
||||
if parsed_time > stir_time_limit:
|
||||
parsed_time = stir_time_limit
|
||||
debug_print(f" 🎮 搅拌时间优化: {original_stir_time}s → {parsed_time}s ⚡")
|
||||
|
||||
|
||||
# 沉降时间限制为30秒
|
||||
settling_time_limit = 30.0
|
||||
if parsed_settling_time > settling_time_limit:
|
||||
parsed_settling_time = settling_time_limit
|
||||
debug_print(f" 🎮 沉降时间优化: {original_settling_time}s → {parsed_settling_time}s ⚡")
|
||||
|
||||
# 参数修正
|
||||
parsed_time, stir_speed, parsed_settling_time = validate_and_fix_params(
|
||||
parsed_time, stir_speed, parsed_settling_time
|
||||
)
|
||||
|
||||
debug_print(f"🎯 最终参数: time={parsed_time}s, speed={stir_speed}RPM, settling={parsed_settling_time}s")
|
||||
|
||||
# 🔍 查找设备
|
||||
debug_print("📍 步骤3: 查找搅拌设备... 🔍")
|
||||
debug_print(f"最终参数: time={parsed_time}s, speed={stir_speed}RPM, settling={parsed_settling_time}s")
|
||||
|
||||
# 查找设备
|
||||
try:
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id) # 🔧 使用 vessel_id
|
||||
debug_print(f"🎉 使用搅拌设备: {stirrer_id} ✨")
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 设备查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"无法找到搅拌设备: {str(e)}")
|
||||
|
||||
# 🚀 生成动作
|
||||
debug_print("📍 步骤4: 生成搅拌动作... 🌪️")
|
||||
|
||||
# 生成动作
|
||||
|
||||
action_sequence = []
|
||||
stir_action = {
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id},
|
||||
"time": str(time),
|
||||
"event": event,
|
||||
"time_spec": time_spec,
|
||||
@@ -244,22 +134,14 @@ def generate_stir_protocol(
|
||||
}
|
||||
}
|
||||
action_sequence.append(stir_action)
|
||||
debug_print("✅ 搅拌动作已添加 🌪️✨")
|
||||
|
||||
# 显示时间优化信息
|
||||
|
||||
# 时间优化信息
|
||||
if original_stir_time != parsed_time or original_settling_time != parsed_settling_time:
|
||||
debug_print(f" 🎭 模拟优化说明:")
|
||||
debug_print(f" 搅拌时间: {original_stir_time/60:.1f}分钟 → {parsed_time/60:.1f}分钟")
|
||||
debug_print(f" 沉降时间: {original_settling_time/60:.1f}分钟 → {parsed_settling_time/60:.1f}分钟")
|
||||
|
||||
# 🎊 总结
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"🎉 搅拌协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f"🥽 搅拌容器: {vessel_display}")
|
||||
debug_print(f"🌪️ 搅拌参数: {stir_speed} RPM, {parsed_time}s, 沉降 {parsed_settling_time}s")
|
||||
debug_print(f"⏱️ 预计总时间: {(parsed_time + parsed_settling_time)/60:.1f} 分钟 ⌛")
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"模拟优化: 搅拌 {original_stir_time/60:.1f}min→{parsed_time/60:.1f}min, "
|
||||
f"沉降 {original_settling_time/60:.1f}min→{parsed_settling_time/60:.1f}min")
|
||||
|
||||
debug_print(f"搅拌协议生成完成: {vessel_display}, {stir_speed}RPM, "
|
||||
f"{parsed_time}s, 沉降{parsed_settling_time}s, 总{(parsed_time + parsed_settling_time)/60:.1f}min")
|
||||
|
||||
return action_sequence
|
||||
|
||||
@@ -272,16 +154,13 @@ def generate_start_stir_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成开始搅拌操作的协议序列 - 修复vessel参数传递"""
|
||||
|
||||
# 🔧 核心修改:正确处理vessel参数
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
|
||||
# 🔧 关键修复:确保vessel_resource是完整的Resource对象
|
||||
|
||||
# 确保vessel_resource是完整的Resource对象
|
||||
if isinstance(vessel, dict):
|
||||
vessel_resource = vessel # 已经是完整的Resource字典
|
||||
debug_print(f"✅ 使用传入的vessel Resource对象")
|
||||
vessel_resource = vessel
|
||||
else:
|
||||
# 如果只是字符串,构建一个基本的Resource对象
|
||||
vessel_resource = {
|
||||
"id": vessel,
|
||||
"name": "",
|
||||
@@ -297,39 +176,29 @@ def generate_start_stir_protocol(
|
||||
"sample_id": "",
|
||||
"type": ""
|
||||
}
|
||||
debug_print(f"🔧 构建了基本的vessel Resource对象: {vessel}")
|
||||
|
||||
debug_print("🔄 开始生成启动搅拌协议(修复vessel参数)✨")
|
||||
debug_print(f"🥽 vessel: {vessel_display} (ID: {vessel_id})")
|
||||
debug_print(f"🌪️ speed: {stir_speed} RPM")
|
||||
debug_print(f"🎯 purpose: {purpose}")
|
||||
|
||||
|
||||
# 基础验证
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
debug_print("❌ 容器验证失败!")
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
# 参数修正
|
||||
if stir_speed < 10.0 or stir_speed > 1500.0:
|
||||
debug_print(f"⚠️ 搅拌速度修正: {stir_speed} → 300 RPM 🌪️")
|
||||
stir_speed = 300.0
|
||||
|
||||
|
||||
# 查找设备
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
|
||||
# 🔧 关键修复:传递vessel_id字符串
|
||||
|
||||
action_sequence = [{
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": purpose or f"启动搅拌 {stir_speed} RPM"
|
||||
}
|
||||
}]
|
||||
|
||||
debug_print(f"✅ 启动搅拌协议生成完成 🎯")
|
||||
|
||||
debug_print(f"启动搅拌协议: {vessel_display}, {stir_speed}RPM, device={stirrer_id}")
|
||||
return action_sequence
|
||||
|
||||
def generate_stop_stir_protocol(
|
||||
@@ -339,16 +208,13 @@ def generate_stop_stir_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成停止搅拌操作的协议序列 - 修复vessel参数传递"""
|
||||
|
||||
# 🔧 核心修改:正确处理vessel参数
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
|
||||
# 🔧 关键修复:确保vessel_resource是完整的Resource对象
|
||||
|
||||
# 确保vessel_resource是完整的Resource对象
|
||||
if isinstance(vessel, dict):
|
||||
vessel_resource = vessel # 已经是完整的Resource字典
|
||||
debug_print(f"✅ 使用传入的vessel Resource对象")
|
||||
vessel_resource = vessel
|
||||
else:
|
||||
# 如果只是字符串,构建一个基本的Resource对象
|
||||
vessel_resource = {
|
||||
"id": vessel,
|
||||
"name": "",
|
||||
@@ -364,115 +230,103 @@ def generate_stop_stir_protocol(
|
||||
"sample_id": "",
|
||||
"type": ""
|
||||
}
|
||||
debug_print(f"🔧 构建了基本的vessel Resource对象: {vessel}")
|
||||
|
||||
debug_print("🛑 开始生成停止搅拌协议(修复vessel参数)✨")
|
||||
debug_print(f"🥽 vessel: {vessel_display} (ID: {vessel_id})")
|
||||
|
||||
|
||||
# 基础验证
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
debug_print("❌ 容器验证失败!")
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
# 查找设备
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
|
||||
# 🔧 关键修复:传递vessel_id字符串
|
||||
|
||||
action_sequence = [{
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id},
|
||||
}
|
||||
}]
|
||||
|
||||
debug_print(f"✅ 停止搅拌协议生成完成 🎯")
|
||||
|
||||
debug_print(f"停止搅拌协议: {vessel_display}, device={stirrer_id}")
|
||||
return action_sequence
|
||||
|
||||
# 🔧 新增:便捷函数
|
||||
# 便捷函数
|
||||
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
speed: float = 300.0) -> List[Dict[str, Any]]:
|
||||
"""短时间搅拌(30秒)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"⚡ 短时间搅拌: {vessel_display} @ {speed}RPM (30s)")
|
||||
debug_print(f"短时间搅拌: {vessel_display} @ {speed}RPM (30s)")
|
||||
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
|
||||
|
||||
def stir_slowly(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "10 min") -> List[Dict[str, Any]]:
|
||||
"""慢速搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🐌 慢速搅拌: {vessel_display} @ 150RPM")
|
||||
debug_print(f"慢速搅拌: {vessel_display} @ 150RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=150.0)
|
||||
|
||||
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "5 min") -> List[Dict[str, Any]]:
|
||||
"""剧烈搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"💨 剧烈搅拌: {vessel_display} @ 800RPM")
|
||||
debug_print(f"剧烈搅拌: {vessel_display} @ 800RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
|
||||
|
||||
def stir_for_reaction(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "1 h") -> List[Dict[str, Any]]:
|
||||
"""反应搅拌(标准速度,长时间)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🧪 反应搅拌: {vessel_display} @ 400RPM")
|
||||
debug_print(f"反应搅拌: {vessel_display} @ 400RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=400.0)
|
||||
|
||||
def stir_for_dissolution(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "15 min") -> List[Dict[str, Any]]:
|
||||
"""溶解搅拌(中等速度)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"💧 溶解搅拌: {vessel_display} @ 500RPM")
|
||||
debug_print(f"溶解搅拌: {vessel_display} @ 500RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=500.0)
|
||||
|
||||
def stir_gently(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "30 min") -> List[Dict[str, Any]]:
|
||||
"""温和搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🍃 温和搅拌: {vessel_display} @ 200RPM")
|
||||
debug_print(f"温和搅拌: {vessel_display} @ 200RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=200.0)
|
||||
|
||||
def stir_overnight(G: nx.DiGraph, vessel: Union[str, dict]) -> List[Dict[str, Any]]:
|
||||
"""过夜搅拌(模拟时缩短为2小时)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🌙 过夜搅拌(模拟2小时): {vessel_display} @ 300RPM")
|
||||
debug_print(f"过夜搅拌(模拟2小时): {vessel_display} @ 300RPM")
|
||||
return generate_stir_protocol(G, vessel, time="2 h", stir_speed=300.0)
|
||||
|
||||
def start_continuous_stirring(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
speed: float = 300.0, purpose: str = "continuous stirring") -> List[Dict[str, Any]]:
|
||||
"""开始连续搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🔄 开始连续搅拌: {vessel_display} @ {speed}RPM")
|
||||
debug_print(f"开始连续搅拌: {vessel_display} @ {speed}RPM")
|
||||
return generate_start_stir_protocol(G, vessel, stir_speed=speed, purpose=purpose)
|
||||
|
||||
def stop_all_stirring(G: nx.DiGraph, vessel: Union[str, dict]) -> List[Dict[str, Any]]:
|
||||
"""停止所有搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🛑 停止搅拌: {vessel_display}")
|
||||
debug_print(f"停止搅拌: {vessel_display}")
|
||||
return generate_stop_stir_protocol(G, vessel)
|
||||
|
||||
# 测试函数
|
||||
def test_stir_protocol():
|
||||
"""测试搅拌协议"""
|
||||
debug_print("🧪 === STIR PROTOCOL 测试 === ✨")
|
||||
|
||||
# 测试vessel参数处理
|
||||
debug_print("🔧 测试vessel参数处理...")
|
||||
|
||||
# 测试字典格式
|
||||
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
|
||||
vessel_id = extract_vessel_id(vessel_dict)
|
||||
vessel_display = get_vessel_display_info(vessel_dict)
|
||||
debug_print(f" 字典格式: {vessel_dict} → ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
debug_print(f"字典格式: {vessel_dict} -> ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
# 测试字符串格式
|
||||
vessel_str = "flask_2"
|
||||
vessel_id = extract_vessel_id(vessel_str)
|
||||
vessel_display = get_vessel_display_info(vessel_str)
|
||||
debug_print(f" 字符串格式: {vessel_str} → ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
debug_print("✅ 测试完成 🎉")
|
||||
debug_print(f"字符串格式: {vessel_str} -> ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_stir_protocol()
|
||||
|
||||
@@ -1,36 +1,57 @@
|
||||
# 🆕 创建进度日志动作
|
||||
"""编译器共享日志工具"""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# 模块名到前缀的映射
|
||||
_MODULE_PREFIXES = {
|
||||
"add_protocol": "[ADD]",
|
||||
"adjustph_protocol": "[ADJUSTPH]",
|
||||
"clean_vessel_protocol": "[CLEAN_VESSEL]",
|
||||
"dissolve_protocol": "[DISSOLVE]",
|
||||
"dry_protocol": "[DRY]",
|
||||
"evacuateandrefill_protocol": "[EVACUATE]",
|
||||
"evaporate_protocol": "[EVAPORATE]",
|
||||
"filter_protocol": "[FILTER]",
|
||||
"heatchill_protocol": "[HEATCHILL]",
|
||||
"hydrogenate_protocol": "[HYDROGENATE]",
|
||||
"pump_protocol": "[PUMP]",
|
||||
"recrystallize_protocol": "[RECRYSTALLIZE]",
|
||||
"reset_handling_protocol": "[RESET]",
|
||||
"run_column_protocol": "[RUN_COLUMN]",
|
||||
"separate_protocol": "[SEPARATE]",
|
||||
"stir_protocol": "[STIR]",
|
||||
"wash_solid_protocol": "[WASH_SOLID]",
|
||||
"vessel_parser": "[VESSEL_PARSER]",
|
||||
"unit_parser": "[UNIT_PARSER]",
|
||||
"resource_helper": "[RESOURCE_HELPER]",
|
||||
}
|
||||
|
||||
def debug_print(message, prefix="[UNIT_PARSER]"):
|
||||
"""调试输出"""
|
||||
|
||||
def debug_print(message, prefix=None):
|
||||
"""调试输出 — 自动根据调用模块设置前缀"""
|
||||
if prefix is None:
|
||||
frame = inspect.currentframe()
|
||||
caller = frame.f_back if frame else None
|
||||
module_name = ""
|
||||
if caller:
|
||||
module_name = caller.f_globals.get("__name__", "")
|
||||
# 取最后一段作为模块短名
|
||||
module_name = module_name.rsplit(".", 1)[-1]
|
||||
prefix = _MODULE_PREFIXES.get(module_name, f"[{module_name.upper()}]")
|
||||
logger = logging.getLogger("unilabos.compile")
|
||||
logger.info(f"{prefix} {message}")
|
||||
|
||||
|
||||
def action_log(message: str, emoji: str = "📝", prefix="[HIGH-LEVEL OPERATION]") -> Dict[str, Any]:
|
||||
"""创建一个动作日志 - 支持中文和emoji"""
|
||||
try:
|
||||
full_message = f"{prefix} {emoji} {message}"
|
||||
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": full_message,
|
||||
"progress_message": full_message
|
||||
}
|
||||
"""创建一个动作日志"""
|
||||
full_message = f"{prefix} {emoji} {message}"
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": full_message,
|
||||
"progress_message": full_message
|
||||
}
|
||||
except Exception as e:
|
||||
# 如果emoji有问题,使用纯文本
|
||||
safe_message = f"{prefix} {message}"
|
||||
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": safe_message,
|
||||
"progress_message": safe_message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
172
unilabos/compile/utils/resource_helper.py
Normal file
172
unilabos/compile/utils/resource_helper.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
资源实例兼容层
|
||||
|
||||
提供 ensure_resource_instance() 将 dict / ResourceDictInstance 统一转为
|
||||
ResourceDictInstance,使编译器可以渐进式迁移到强类型资源。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from unilabos.resources.resource_tracker import ResourceDictInstance
|
||||
|
||||
|
||||
def ensure_resource_instance(
|
||||
resource: Union[Dict[str, Any], ResourceDictInstance, None],
|
||||
) -> Optional[ResourceDictInstance]:
|
||||
"""将 dict 或 ResourceDictInstance 统一转为 ResourceDictInstance
|
||||
|
||||
编译器入口统一调用此函数,即可同时兼容旧 dict 传参和新 ResourceDictInstance 传参。
|
||||
|
||||
Args:
|
||||
resource: 资源数据,可以是 plain dict、ResourceDictInstance 或 None
|
||||
|
||||
Returns:
|
||||
ResourceDictInstance 或 None(当输入为 None 时)
|
||||
"""
|
||||
if resource is None:
|
||||
return None
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
return resource
|
||||
if isinstance(resource, dict):
|
||||
return ResourceDictInstance.get_resource_instance_from_dict(resource)
|
||||
raise TypeError(f"不支持的资源类型: {type(resource)}, 期望 dict 或 ResourceDictInstance")
|
||||
|
||||
|
||||
def resource_to_dict(resource: Union[Dict[str, Any], ResourceDictInstance]) -> Dict[str, Any]:
|
||||
"""将 ResourceDictInstance 或 dict 统一转为 plain dict
|
||||
|
||||
用于需要 dict 操作的场景(如 children dict 操作)。
|
||||
|
||||
Args:
|
||||
resource: ResourceDictInstance 或 dict
|
||||
|
||||
Returns:
|
||||
plain dict
|
||||
"""
|
||||
if isinstance(resource, dict):
|
||||
return resource
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
return resource.get_plr_nested_dict()
|
||||
raise TypeError(f"不支持的资源类型: {type(resource)}")
|
||||
|
||||
|
||||
def get_resource_id(resource: Union[str, Dict[str, Any], ResourceDictInstance]) -> str:
|
||||
"""从资源对象中提取 ID
|
||||
|
||||
Args:
|
||||
resource: 字符串 ID、dict 或 ResourceDictInstance
|
||||
|
||||
Returns:
|
||||
资源 ID 字符串
|
||||
"""
|
||||
if isinstance(resource, str):
|
||||
return resource
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
return resource.res_content.id
|
||||
if isinstance(resource, dict):
|
||||
if "id" in resource:
|
||||
return resource["id"]
|
||||
# 兼容 {station_id: {...}} 格式
|
||||
first_val = next(iter(resource.values()), {})
|
||||
if isinstance(first_val, dict):
|
||||
return first_val.get("id", "")
|
||||
return ""
|
||||
raise TypeError(f"不支持的资源类型: {type(resource)}")
|
||||
|
||||
|
||||
def get_resource_data(resource: Union[str, Dict[str, Any], ResourceDictInstance]) -> Dict[str, Any]:
|
||||
"""从资源对象中提取 data 字段
|
||||
|
||||
Args:
|
||||
resource: 字符串、dict 或 ResourceDictInstance
|
||||
|
||||
Returns:
|
||||
data 字典
|
||||
"""
|
||||
if isinstance(resource, str):
|
||||
return {}
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
return dict(resource.res_content.data)
|
||||
if isinstance(resource, dict):
|
||||
return resource.get("data", {})
|
||||
return {}
|
||||
|
||||
|
||||
def get_resource_display_info(resource: Union[str, Dict[str, Any], ResourceDictInstance]) -> str:
|
||||
"""获取资源的显示信息(用于日志)
|
||||
|
||||
Args:
|
||||
resource: 字符串 ID、dict 或 ResourceDictInstance
|
||||
|
||||
Returns:
|
||||
显示信息字符串
|
||||
"""
|
||||
if isinstance(resource, str):
|
||||
return resource
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
res = resource.res_content
|
||||
return f"{res.id} ({res.name})" if res.name and res.name != res.id else res.id
|
||||
if isinstance(resource, dict):
|
||||
res_id = resource.get("id", "unknown")
|
||||
res_name = resource.get("name", "")
|
||||
if res_name and res_name != res_id:
|
||||
return f"{res_id} ({res_name})"
|
||||
return res_id
|
||||
return str(resource)
|
||||
|
||||
|
||||
def get_resource_liquid_volume(resource: Union[Dict[str, Any], ResourceDictInstance]) -> float:
|
||||
"""从资源中获取液体体积
|
||||
|
||||
Args:
|
||||
resource: dict 或 ResourceDictInstance
|
||||
|
||||
Returns:
|
||||
液体总体积 (mL)
|
||||
"""
|
||||
data = get_resource_data(resource)
|
||||
liquids = data.get("liquid", [])
|
||||
if isinstance(liquids, list):
|
||||
return sum(l.get("volume", 0.0) for l in liquids if isinstance(l, dict))
|
||||
return 0.0
|
||||
|
||||
|
||||
def update_vessel_volume(vessel, G, new_volume: float, description: str = "") -> None:
|
||||
"""
|
||||
更新容器体积(同时更新vessel字典和图节点)
|
||||
|
||||
Args:
|
||||
vessel: 容器字典或 ResourceDictInstance
|
||||
G: 网络图 (nx.DiGraph)
|
||||
new_volume: 新体积 (mL)
|
||||
description: 更新描述(用于日志)
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger("unilabos.compile")
|
||||
|
||||
vessel_id = get_resource_id(vessel)
|
||||
|
||||
if description:
|
||||
logger.info(f"[RESOURCE] 更新容器体积 - {description}")
|
||||
|
||||
# 更新 vessel 字典中的体积
|
||||
if isinstance(vessel, dict):
|
||||
if "data" not in vessel:
|
||||
vessel["data"] = {}
|
||||
lv = vessel["data"].get("liquid_volume")
|
||||
if isinstance(lv, list) and len(lv) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id and vessel_id in G.nodes():
|
||||
if "data" not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]["data"] = {}
|
||||
node_lv = G.nodes[vessel_id]["data"].get("liquid_volume")
|
||||
if isinstance(node_lv, list) and len(node_lv) > 0:
|
||||
G.nodes[vessel_id]["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
G.nodes[vessel_id]["data"]["liquid_volume"] = new_volume
|
||||
|
||||
logger.info(f"[RESOURCE] 容器 '{vessel_id}' 体积已更新为: {new_volume:.2f}mL")
|
||||
@@ -184,6 +184,42 @@ def parse_time_input(time_input: Union[str, float]) -> float:
|
||||
|
||||
return time_sec
|
||||
|
||||
|
||||
def parse_temperature_input(temp_input: Union[str, float], default_temp: float = 25.0) -> float:
|
||||
"""
|
||||
解析温度输入,支持字符串和数值
|
||||
|
||||
Args:
|
||||
temp_input: 温度输入(如 "256 °C", "reflux", 45.0)
|
||||
default_temp: 默认温度
|
||||
|
||||
Returns:
|
||||
float: 温度(°C)
|
||||
"""
|
||||
if not temp_input:
|
||||
return default_temp
|
||||
|
||||
if isinstance(temp_input, (int, float)):
|
||||
return float(temp_input)
|
||||
|
||||
temp_str = str(temp_input).lower().strip()
|
||||
|
||||
# 特殊温度关键词
|
||||
special_temps = {
|
||||
"room temperature": 25.0, "reflux": 78.0, "ice bath": 0.0,
|
||||
"boiling": 100.0, "hot": 60.0, "warm": 40.0, "cold": 10.0,
|
||||
}
|
||||
if temp_str in special_temps:
|
||||
return special_temps[temp_str]
|
||||
|
||||
# 正则解析(如 "256 °C", "45°C", "45")
|
||||
match = re.search(r'(\d+(?:\.\d+)?)\s*°?[cf]?', temp_str)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
|
||||
debug_print(f"无法解析温度: '{temp_str}',使用默认值: {default_temp}°C")
|
||||
return default_temp
|
||||
|
||||
# 测试函数
|
||||
def test_unit_parser():
|
||||
"""测试单位解析功能"""
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import networkx as nx
|
||||
|
||||
from .logger_util import debug_print
|
||||
from .resource_helper import get_resource_id, get_resource_data
|
||||
|
||||
|
||||
def get_vessel(vessel):
|
||||
"""
|
||||
统一处理vessel参数,返回vessel_id和vessel_data。
|
||||
支持 dict、str、ResourceDictInstance。
|
||||
|
||||
Args:
|
||||
vessel: 可以是一个字典或字符串,表示vessel的ID或数据。
|
||||
vessel: 可以是一个字典、字符串或 ResourceDictInstance,表示vessel的ID或数据。
|
||||
|
||||
Returns:
|
||||
tuple: 包含vessel_id和vessel_data。
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
if "id" not in vessel:
|
||||
vessel_id = list(vessel.values())[0].get("id", "")
|
||||
else:
|
||||
vessel_id = vessel.get("id", "")
|
||||
vessel_data = vessel.get("data", {})
|
||||
else:
|
||||
vessel_id = str(vessel)
|
||||
vessel_data = {}
|
||||
# 统一使用 resource_helper 处理
|
||||
vessel_id = get_resource_id(vessel)
|
||||
vessel_data = get_resource_data(vessel)
|
||||
return vessel_id, vessel_data
|
||||
|
||||
|
||||
@@ -278,4 +274,31 @@ def find_solid_dispenser(G: nx.DiGraph) -> str:
|
||||
return node
|
||||
|
||||
debug_print(f"❌ 未找到固体加样器")
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def find_connected_heatchill(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与指定容器相连的加热/冷却设备"""
|
||||
heatchill_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
node_name = node.lower()
|
||||
if ('heatchill' in node_class.lower() or 'virtual_heatchill' in node_class
|
||||
or 'heater' in node_name or 'heat' in node_name):
|
||||
heatchill_nodes.append(node)
|
||||
|
||||
# 检查连接
|
||||
if vessel and heatchill_nodes:
|
||||
for hc in heatchill_nodes:
|
||||
if G.has_edge(hc, vessel) or G.has_edge(vessel, hc):
|
||||
debug_print(f"加热设备 '{hc}' 与容器 '{vessel}' 相连")
|
||||
return hc
|
||||
|
||||
# 使用第一个可用设备
|
||||
if heatchill_nodes:
|
||||
debug_print(f"使用第一个加热设备: {heatchill_nodes[0]}")
|
||||
return heatchill_nodes[0]
|
||||
|
||||
debug_print("未找到加热设备,使用默认设备")
|
||||
return "heatchill_1"
|
||||
@@ -4,199 +4,55 @@ import logging
|
||||
import re
|
||||
|
||||
from .utils.unit_parser import parse_time_input, parse_volume_input
|
||||
from .utils.resource_helper import get_resource_id, get_resource_display_info, get_resource_liquid_volume, update_vessel_volume
|
||||
from .utils.logger_util import debug_print
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[WASH_SOLID] {message}")
|
||||
|
||||
|
||||
def find_solvent_source(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""查找溶剂源(精简版)"""
|
||||
debug_print(f"🔍 查找溶剂源: {solvent}")
|
||||
|
||||
# 简化搜索列表
|
||||
"""查找溶剂源"""
|
||||
search_patterns = [
|
||||
f"flask_{solvent}", f"bottle_{solvent}", f"reagent_{solvent}",
|
||||
"liquid_reagent_bottle_1", "flask_1", "solvent_bottle"
|
||||
]
|
||||
|
||||
|
||||
for pattern in search_patterns:
|
||||
if pattern in G.nodes():
|
||||
debug_print(f"🎉 找到溶剂源: {pattern}")
|
||||
debug_print(f"找到溶剂源: {pattern}")
|
||||
return pattern
|
||||
|
||||
debug_print(f"⚠️ 使用默认溶剂源: flask_{solvent}")
|
||||
|
||||
debug_print(f"使用默认溶剂源: flask_{solvent}")
|
||||
return f"flask_{solvent}"
|
||||
|
||||
def find_filtrate_vessel(G: nx.DiGraph, filtrate_vessel: str = "") -> str:
|
||||
"""查找滤液容器(精简版)"""
|
||||
debug_print(f"🔍 查找滤液容器: {filtrate_vessel}")
|
||||
|
||||
# 如果指定了且存在,直接使用
|
||||
"""查找滤液容器"""
|
||||
if filtrate_vessel and filtrate_vessel in G.nodes():
|
||||
debug_print(f"✅ 使用指定容器: {filtrate_vessel}")
|
||||
return filtrate_vessel
|
||||
|
||||
# 简化搜索列表
|
||||
|
||||
default_vessels = ["waste_workup", "filtrate_vessel", "flask_1", "collection_bottle_1"]
|
||||
|
||||
|
||||
for vessel in default_vessels:
|
||||
if vessel in G.nodes():
|
||||
debug_print(f"🎉 找到滤液容器: {vessel}")
|
||||
debug_print(f"找到滤液容器: {vessel}")
|
||||
return vessel
|
||||
|
||||
debug_print(f"⚠️ 使用默认滤液容器: waste_workup")
|
||||
|
||||
return "waste_workup"
|
||||
|
||||
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||
"""
|
||||
从vessel参数中提取vessel_id
|
||||
|
||||
Args:
|
||||
vessel: vessel字典或vessel_id字符串
|
||||
|
||||
Returns:
|
||||
str: vessel_id
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
vessel_id = list(vessel.values())[0].get("id", "")
|
||||
debug_print(f"🔧 从vessel字典提取ID: {vessel_id}")
|
||||
return vessel_id
|
||||
elif isinstance(vessel, str):
|
||||
debug_print(f"🔧 vessel参数为字符串: {vessel}")
|
||||
return vessel
|
||||
else:
|
||||
debug_print(f"⚠️ 无效的vessel参数类型: {type(vessel)}")
|
||||
return ""
|
||||
def extract_vessel_id(vessel) -> str:
|
||||
"""从vessel参数中提取vessel_id,兼容 str / dict / ResourceDictInstance"""
|
||||
return get_resource_id(vessel)
|
||||
|
||||
def get_vessel_display_info(vessel: Union[str, dict]) -> str:
|
||||
"""
|
||||
获取容器的显示信息(用于日志)
|
||||
|
||||
Args:
|
||||
vessel: vessel字典或vessel_id字符串
|
||||
|
||||
Returns:
|
||||
str: 显示信息
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
vessel_name = vessel.get("name", "")
|
||||
if vessel_name:
|
||||
return f"{vessel_id} ({vessel_name})"
|
||||
else:
|
||||
return vessel_id
|
||||
else:
|
||||
return str(vessel)
|
||||
|
||||
def get_vessel_liquid_volume(vessel: dict) -> float:
|
||||
"""
|
||||
获取容器中的液体体积 - 支持vessel字典
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
|
||||
Returns:
|
||||
float: 液体体积(mL)
|
||||
"""
|
||||
if not vessel or "data" not in vessel:
|
||||
debug_print(f"⚠️ 容器数据为空,返回 0.0mL")
|
||||
return 0.0
|
||||
|
||||
vessel_data = vessel["data"]
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
debug_print(f"🔍 读取容器 '{vessel_id}' 体积数据: {vessel_data}")
|
||||
|
||||
# 检查liquid_volume字段
|
||||
if "liquid_volume" in vessel_data:
|
||||
liquid_volume = vessel_data["liquid_volume"]
|
||||
|
||||
# 处理列表格式
|
||||
if isinstance(liquid_volume, list):
|
||||
if len(liquid_volume) > 0:
|
||||
volume = liquid_volume[0]
|
||||
if isinstance(volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (列表格式)")
|
||||
return float(volume)
|
||||
|
||||
# 处理直接数值格式
|
||||
elif isinstance(liquid_volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {liquid_volume}mL (数值格式)")
|
||||
return float(liquid_volume)
|
||||
|
||||
# 检查其他可能的体积字段
|
||||
volume_keys = ['current_volume', 'total_volume', 'volume']
|
||||
for key in volume_keys:
|
||||
if key in vessel_data:
|
||||
try:
|
||||
volume = float(vessel_data[key])
|
||||
if volume > 0:
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (字段: {key})")
|
||||
return volume
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
debug_print(f"⚠️ 无法获取容器 '{vessel_id}' 的体积,返回默认值 0.0mL")
|
||||
return 0.0
|
||||
|
||||
def update_vessel_volume(vessel: dict, G: nx.DiGraph, new_volume: float, description: str = "") -> None:
|
||||
"""
|
||||
更新容器体积(同时更新vessel字典和图节点)
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
G: 网络图
|
||||
new_volume: 新体积
|
||||
description: 更新描述
|
||||
"""
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
if description:
|
||||
debug_print(f"🔧 更新容器体积 - {description}")
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
if "data" in vessel:
|
||||
if "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [new_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"] = {"liquid_volume": new_volume}
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
debug_print(f"📊 容器 '{vessel_id}' 体积已更新为: {new_volume:.2f}mL")
|
||||
def get_vessel_display_info(vessel) -> str:
|
||||
"""获取容器的显示信息(用于日志),兼容 str / dict / ResourceDictInstance"""
|
||||
return get_resource_display_info(vessel)
|
||||
|
||||
def generate_wash_solid_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: Union[str, dict], # 🔧 修改:支持vessel字典
|
||||
vessel: Union[str, dict],
|
||||
solvent: str,
|
||||
volume: Union[float, str] = "50",
|
||||
filtrate_vessel: Union[str, dict] = "", # 🔧 修改:支持vessel字典
|
||||
filtrate_vessel: Union[str, dict] = "",
|
||||
temp: float = 25.0,
|
||||
stir: bool = False,
|
||||
stir_speed: float = 0.0,
|
||||
@@ -210,7 +66,7 @@ def generate_wash_solid_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成固体清洗协议 - 支持vessel字典和体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为设备和容器,边为流体管道
|
||||
vessel: 清洗容器字典(从XDL传入)或容器ID字符串
|
||||
@@ -227,106 +83,78 @@ def generate_wash_solid_protocol(
|
||||
mass: 固体质量(用于计算溶剂用量)
|
||||
event: 事件描述
|
||||
**kwargs: 其他可选参数
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 固体清洗操作的动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从vessel参数中提取vessel_id
|
||||
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
|
||||
# 🔧 处理filtrate_vessel参数
|
||||
|
||||
filtrate_vessel_id = extract_vessel_id(filtrate_vessel) if filtrate_vessel else ""
|
||||
|
||||
debug_print("🧼" * 20)
|
||||
debug_print("🚀 开始生成固体清洗协议(支持vessel字典和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel_display} (ID: {vessel_id})")
|
||||
debug_print(f" 🧪 solvent: {solvent}")
|
||||
debug_print(f" 💧 volume: {volume}")
|
||||
debug_print(f" 🗑️ filtrate_vessel: {filtrate_vessel_id}")
|
||||
debug_print(f" ⏰ time: {time}")
|
||||
debug_print(f" 🔄 repeats: {repeats}")
|
||||
debug_print("🧼" * 20)
|
||||
|
||||
# 🔧 新增:记录清洗前的容器状态
|
||||
debug_print("🔍 记录清洗前容器状态...")
|
||||
|
||||
debug_print(f"开始生成固体清洗协议: vessel={vessel_id}, solvent={solvent}, volume={volume}, repeats={repeats}")
|
||||
|
||||
# 记录清洗前的容器状态
|
||||
if isinstance(vessel, dict):
|
||||
original_volume = get_vessel_liquid_volume(vessel)
|
||||
debug_print(f"📊 清洗前液体体积: {original_volume:.2f}mL")
|
||||
original_volume = get_resource_liquid_volume(vessel)
|
||||
else:
|
||||
original_volume = 0.0
|
||||
debug_print(f"📊 vessel为字符串格式,无法获取体积信息")
|
||||
|
||||
# 📋 快速验证
|
||||
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print("❌ 容器验证失败! 😱")
|
||||
|
||||
# 快速验证
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
if not solvent:
|
||||
debug_print("❌ 溶剂不能为空! 😱")
|
||||
raise ValueError("solvent 参数不能为空")
|
||||
|
||||
debug_print("✅ 基础验证通过 🎯")
|
||||
|
||||
# 🔄 参数解析
|
||||
debug_print("📍 步骤1: 参数解析... ⚡")
|
||||
|
||||
# 参数解析
|
||||
final_volume = parse_volume_input(volume, volume_spec, mass)
|
||||
final_time = parse_time_input(time)
|
||||
|
||||
# 重复次数处理(简化)
|
||||
|
||||
# 重复次数处理
|
||||
if repeats_spec:
|
||||
spec_map = {'few': 2, 'several': 3, 'many': 4, 'thorough': 5}
|
||||
final_repeats = next((v for k, v in spec_map.items() if k in repeats_spec.lower()), repeats)
|
||||
else:
|
||||
final_repeats = max(1, min(repeats, 5)) # 限制1-5次
|
||||
|
||||
# 🕐 模拟时间优化
|
||||
debug_print(" ⏱️ 模拟时间优化...")
|
||||
final_repeats = max(1, min(repeats, 5))
|
||||
|
||||
# 模拟时间优化
|
||||
original_time = final_time
|
||||
if final_time > 60.0:
|
||||
final_time = 60.0 # 限制最长60秒
|
||||
debug_print(f" 🎮 时间优化: {original_time}s → {final_time}s ⚡")
|
||||
|
||||
final_time = 60.0
|
||||
debug_print(f"时间优化: {original_time}s -> {final_time}s")
|
||||
|
||||
# 参数修正
|
||||
temp = max(25.0, min(temp, 80.0)) # 温度范围25-80°C
|
||||
stir_speed = max(0.0, min(stir_speed, 300.0)) if stir else 0.0 # 速度范围0-300
|
||||
|
||||
debug_print(f"🎯 最终参数: 体积={final_volume}mL, 时间={final_time}s, 重复={final_repeats}次")
|
||||
|
||||
# 🔍 查找设备
|
||||
debug_print("📍 步骤2: 查找设备... 🔍")
|
||||
temp = max(25.0, min(temp, 80.0))
|
||||
stir_speed = max(0.0, min(stir_speed, 300.0)) if stir else 0.0
|
||||
|
||||
debug_print(f"最终参数: 体积={final_volume}mL, 时间={final_time}s, 重复={final_repeats}次")
|
||||
|
||||
# 查找设备
|
||||
try:
|
||||
solvent_source = find_solvent_source(G, solvent)
|
||||
actual_filtrate_vessel = find_filtrate_vessel(G, filtrate_vessel_id)
|
||||
debug_print(f"🎉 设备配置完成 ✨")
|
||||
debug_print(f" 🧪 溶剂源: {solvent_source}")
|
||||
debug_print(f" 🗑️ 滤液容器: {actual_filtrate_vessel}")
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 设备查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"设备查找失败: {str(e)}")
|
||||
|
||||
# 🚀 生成动作序列
|
||||
debug_print("📍 步骤3: 生成清洗动作... 🧼")
|
||||
|
||||
# 生成动作序列
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 新增:体积变化跟踪变量
|
||||
|
||||
current_volume = original_volume
|
||||
total_solvent_used = 0.0
|
||||
|
||||
|
||||
for cycle in range(final_repeats):
|
||||
debug_print(f" 🔄 第{cycle+1}/{final_repeats}次清洗...")
|
||||
|
||||
debug_print(f"第{cycle+1}/{final_repeats}次清洗")
|
||||
|
||||
# 1. 转移溶剂
|
||||
try:
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
debug_print(f" 💧 添加溶剂: {final_volume}mL {solvent}")
|
||||
|
||||
transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent_source,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
to_vessel=vessel_id,
|
||||
volume=final_volume,
|
||||
amount="",
|
||||
time=0.0,
|
||||
@@ -338,211 +166,160 @@ def generate_wash_solid_protocol(
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5
|
||||
)
|
||||
|
||||
|
||||
if transfer_actions:
|
||||
action_sequence.extend(transfer_actions)
|
||||
debug_print(f" ✅ 转移动作: {len(transfer_actions)}个 🚚")
|
||||
|
||||
# 🔧 新增:更新体积 - 添加溶剂后
|
||||
|
||||
current_volume += final_volume
|
||||
total_solvent_used += final_volume
|
||||
|
||||
|
||||
if isinstance(vessel, dict):
|
||||
update_vessel_volume(vessel, G, current_volume,
|
||||
update_vessel_volume(vessel, G, current_volume,
|
||||
f"第{cycle+1}次清洗添加{final_volume}mL溶剂后")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 转移失败: {str(e)} 😞")
|
||||
|
||||
debug_print(f"转移失败: {str(e)}")
|
||||
|
||||
# 2. 搅拌(如果需要)
|
||||
if stir and final_time > 0:
|
||||
debug_print(f" 🌪️ 搅拌: {final_time}s @ {stir_speed}RPM")
|
||||
stir_action = {
|
||||
"device_id": "stirrer_1",
|
||||
"action_name": "stir",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"time": str(time),
|
||||
"stir_time": final_time,
|
||||
"stir_speed": stir_speed,
|
||||
"settling_time": 10.0 # 🕐 缩短沉降时间
|
||||
"settling_time": 10.0
|
||||
}
|
||||
}
|
||||
action_sequence.append(stir_action)
|
||||
debug_print(f" ✅ 搅拌动作: {final_time}s, {stir_speed}RPM 🌪️")
|
||||
|
||||
|
||||
# 3. 过滤
|
||||
debug_print(f" 🌊 过滤到: {actual_filtrate_vessel}")
|
||||
filter_action = {
|
||||
"device_id": "filter_1",
|
||||
"action_name": "filter",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"filtrate_vessel": actual_filtrate_vessel,
|
||||
"temp": temp,
|
||||
"volume": final_volume
|
||||
}
|
||||
}
|
||||
action_sequence.append(filter_action)
|
||||
debug_print(f" ✅ 过滤动作: → {actual_filtrate_vessel} 🌊")
|
||||
|
||||
# 🔧 新增:更新体积 - 过滤后(液体被滤除)
|
||||
# 假设滤液完全被移除,固体残留在容器中
|
||||
filtered_volume = current_volume * 0.9 # 假设90%的液体被过滤掉
|
||||
|
||||
# 更新体积 - 过滤后
|
||||
filtered_volume = current_volume * 0.9
|
||||
current_volume = current_volume - filtered_volume
|
||||
|
||||
|
||||
if isinstance(vessel, dict):
|
||||
update_vessel_volume(vessel, G, current_volume,
|
||||
update_vessel_volume(vessel, G, current_volume,
|
||||
f"第{cycle+1}次清洗过滤后")
|
||||
|
||||
# 4. 等待(缩短时间)
|
||||
wait_time = 5.0 # 🕐 缩短等待时间:10s → 5s
|
||||
|
||||
# 4. 等待
|
||||
wait_time = 5.0
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": wait_time}
|
||||
})
|
||||
debug_print(f" ✅ 等待: {wait_time}s ⏰")
|
||||
|
||||
# 🔧 新增:清洗完成后的最终状态报告
|
||||
|
||||
# 最终状态
|
||||
if isinstance(vessel, dict):
|
||||
final_volume_vessel = get_vessel_liquid_volume(vessel)
|
||||
final_volume_vessel = get_resource_liquid_volume(vessel)
|
||||
else:
|
||||
final_volume_vessel = current_volume
|
||||
|
||||
# 🎊 总结
|
||||
debug_print("🧼" * 20)
|
||||
debug_print(f"🎉 固体清洗协议生成完成! ✨")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f" 🥽 清洗容器: {vessel_display}")
|
||||
debug_print(f" 🧪 使用溶剂: {solvent}")
|
||||
debug_print(f" 💧 单次体积: {final_volume}mL")
|
||||
debug_print(f" 🔄 清洗次数: {final_repeats}次")
|
||||
debug_print(f" 💧 总溶剂用量: {total_solvent_used:.2f}mL")
|
||||
debug_print(f"📊 体积变化统计:")
|
||||
debug_print(f" - 清洗前体积: {original_volume:.2f}mL")
|
||||
debug_print(f" - 清洗后体积: {final_volume_vessel:.2f}mL")
|
||||
debug_print(f" - 溶剂总用量: {total_solvent_used:.2f}mL")
|
||||
debug_print(f"⏱️ 预计总时间: {(final_time + 5) * final_repeats / 60:.1f} 分钟")
|
||||
debug_print("🧼" * 20)
|
||||
|
||||
|
||||
debug_print(f"固体清洗协议生成完成: {len(action_sequence)} 个动作, {final_repeats}次清洗, 溶剂总用量={total_solvent_used:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
# 🔧 新增:便捷函数
|
||||
def wash_with_water(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "50",
|
||||
# 便捷函数
|
||||
def wash_with_water(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "50",
|
||||
repeats: int = 2) -> List[Dict[str, Any]]:
|
||||
"""用水清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"💧 水洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, "water", volume=volume, repeats=repeats)
|
||||
|
||||
def wash_with_ethanol(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "30",
|
||||
def wash_with_ethanol(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "30",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""用乙醇清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🍺 乙醇洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, "ethanol", volume=volume, repeats=repeats)
|
||||
|
||||
def wash_with_acetone(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "25",
|
||||
def wash_with_acetone(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "25",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""用丙酮清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"💨 丙酮洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, "acetone", volume=volume, repeats=repeats)
|
||||
|
||||
def wash_with_ether(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "40",
|
||||
def wash_with_ether(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "40",
|
||||
repeats: int = 2) -> List[Dict[str, Any]]:
|
||||
"""用乙醚清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🌬️ 乙醚洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, "diethyl_ether", volume=volume, repeats=repeats)
|
||||
|
||||
def wash_with_cold_solvent(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "30",
|
||||
def wash_with_cold_solvent(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "30",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""用冷溶剂清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"❄️ 冷{solvent}洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
temp=5.0, repeats=repeats)
|
||||
|
||||
def wash_with_hot_solvent(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50",
|
||||
def wash_with_hot_solvent(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""用热溶剂清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🔥 热{solvent}洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
temp=60.0, repeats=repeats)
|
||||
|
||||
def wash_with_stirring(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50",
|
||||
stir_time: Union[str, float] = "5 min",
|
||||
def wash_with_stirring(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50",
|
||||
stir_time: Union[str, float] = "5 min",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""带搅拌的溶剂清洗"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🌪️ 搅拌清洗: {vessel_display} with {solvent} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
stir=True, stir_speed=200.0,
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
stir=True, stir_speed=200.0,
|
||||
time=stir_time, repeats=repeats)
|
||||
|
||||
def thorough_wash(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
def thorough_wash(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50") -> List[Dict[str, Any]]:
|
||||
"""彻底清洗(多次重复)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🔄 彻底清洗: {vessel_display} with {solvent} (5 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume, repeats=5)
|
||||
|
||||
def quick_rinse(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
def quick_rinse(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "20") -> List[Dict[str, Any]]:
|
||||
"""快速冲洗(单次,小体积)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"⚡ 快速冲洗: {vessel_display} with {solvent}")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume, repeats=1)
|
||||
|
||||
def sequential_wash(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
def sequential_wash(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvents: list, volume: Union[float, str] = "40") -> List[Dict[str, Any]]:
|
||||
"""连续多溶剂清洗"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"📝 连续清洗: {vessel_display} with {' → '.join(solvents)}")
|
||||
|
||||
action_sequence = []
|
||||
for solvent in solvents:
|
||||
wash_actions = generate_wash_solid_protocol(G, vessel, solvent,
|
||||
wash_actions = generate_wash_solid_protocol(G, vessel, solvent,
|
||||
volume=volume, repeats=1)
|
||||
action_sequence.extend(wash_actions)
|
||||
|
||||
|
||||
return action_sequence
|
||||
|
||||
# 测试函数
|
||||
def test_wash_solid_protocol():
|
||||
"""测试固体清洗协议"""
|
||||
debug_print("🧪 === WASH SOLID PROTOCOL 测试 === ✨")
|
||||
|
||||
# 测试vessel参数处理
|
||||
debug_print("🔧 测试vessel参数处理...")
|
||||
|
||||
# 测试字典格式
|
||||
vessel_dict = {"id": "filter_flask_1", "name": "过滤瓶1",
|
||||
debug_print("=== WASH SOLID PROTOCOL 测试 ===")
|
||||
|
||||
vessel_dict = {"id": "filter_flask_1", "name": "过滤瓶1",
|
||||
"data": {"liquid_volume": 25.0}}
|
||||
vessel_id = extract_vessel_id(vessel_dict)
|
||||
vessel_display = get_vessel_display_info(vessel_dict)
|
||||
volume = get_vessel_liquid_volume(vessel_dict)
|
||||
debug_print(f" 字典格式: {vessel_dict}")
|
||||
debug_print(f" → ID: {vessel_id}, 显示: {vessel_display}, 体积: {volume}mL")
|
||||
|
||||
# 测试字符串格式
|
||||
volume = get_resource_liquid_volume(vessel_dict)
|
||||
debug_print(f"字典格式: ID={vessel_id}, 显示={vessel_display}, 体积={volume}mL")
|
||||
|
||||
vessel_str = "filter_flask_2"
|
||||
vessel_id = extract_vessel_id(vessel_str)
|
||||
vessel_display = get_vessel_display_info(vessel_str)
|
||||
debug_print(f" 字符串格式: {vessel_str}")
|
||||
debug_print(f" → ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
debug_print("✅ 测试完成 🎉")
|
||||
debug_print(f"字符串格式: ID={vessel_id}, 显示={vessel_display}")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_wash_solid_protocol()
|
||||
test_wash_solid_protocol()
|
||||
|
||||
@@ -24,6 +24,7 @@ class BasicConfig:
|
||||
port = 8002 # 本地HTTP服务
|
||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
||||
extra_resource = False # 是否加载lab_开头的额外资源
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@@ -40,7 +41,7 @@ class BasicConfig:
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
ping_interval = 20 # ping间隔(秒)
|
||||
|
||||
|
||||
# HTTP配置
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
from functools import wraps
|
||||
import inspect
|
||||
|
||||
@@ -57,18 +57,6 @@ 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
|
||||
|
||||
@@ -55,6 +55,7 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||
TransferLiquidReturn,
|
||||
)
|
||||
from unilabos.registry.placeholder_type import ResourceSlot
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
@@ -90,20 +91,103 @@ class PRCXI9300Deck(Deck):
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||
# T1-T16 默认位置 (4列×4行)
|
||||
_DEFAULT_SITE_POSITIONS = [
|
||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
||||
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T9-T12
|
||||
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T13-T16
|
||||
]
|
||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
|
||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor"]
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位
|
||||
self.slot_locations = [Coordinate(0, 0, 0)] * 16
|
||||
if sites is not None:
|
||||
self.sites: List[Dict[str, Any]] = [dict(s) for s in sites]
|
||||
else:
|
||||
self.sites = []
|
||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
||||
self.sites.append({
|
||||
"label": f"T{i + 1}",
|
||||
"visible": True,
|
||||
"position": {"x": x, "y": y, "z": z},
|
||||
"size": dict(self._DEFAULT_SITE_SIZE),
|
||||
"content_type": list(self._DEFAULT_CONTENT_TYPE),
|
||||
})
|
||||
# _ordering: label -> None, 用于外部通过 list(keys()).index(site) 将 Tn 转换为 spot index
|
||||
self._ordering = collections.OrderedDict(
|
||||
(site["label"], None) for site in self.sites
|
||||
)
|
||||
|
||||
def _get_site_location(self, idx: int) -> Coordinate:
|
||||
pos = self.sites[idx]["position"]
|
||||
return Coordinate(pos["x"], pos["y"], pos["z"])
|
||||
|
||||
def _get_site_resource(self, idx: int) -> Optional[Resource]:
|
||||
site_loc = self._get_site_location(idx)
|
||||
for child in self.children:
|
||||
if child.location == site_loc:
|
||||
return child
|
||||
return None
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: Resource,
|
||||
location: Optional[Coordinate] = None,
|
||||
reassign: bool = True,
|
||||
spot: Optional[int] = None,
|
||||
):
|
||||
idx = spot
|
||||
if spot is not None:
|
||||
idx = spot
|
||||
else:
|
||||
for i, site in enumerate(self.sites):
|
||||
site_loc = self._get_site_location(i)
|
||||
if site.get("label") == resource.name:
|
||||
idx = i
|
||||
break
|
||||
if location is not None and site_loc == location:
|
||||
idx = i
|
||||
break
|
||||
|
||||
if idx is None:
|
||||
for i in range(len(self.sites)):
|
||||
if self._get_site_resource(i) is None:
|
||||
idx = i
|
||||
break
|
||||
|
||||
if idx is None:
|
||||
raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'")
|
||||
|
||||
if not reassign and self._get_site_resource(idx) is not None:
|
||||
raise ValueError(f"Site {idx} ('{self.sites[idx]['label']}') is already occupied")
|
||||
|
||||
loc = self._get_site_location(idx)
|
||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
||||
|
||||
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
|
||||
if self.slots[slot - 1] is not None and not reassign:
|
||||
raise ValueError(f"Spot {slot} is already occupied")
|
||||
self.assign_child_resource(resource, spot=slot - 1, reassign=reassign)
|
||||
|
||||
self.slots[slot - 1] = resource
|
||||
super().assign_child_resource(resource, location=self.slot_locations[slot - 1])
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
sites_out = []
|
||||
for i, site in enumerate(self.sites):
|
||||
occupied = self._get_site_resource(i)
|
||||
sites_out.append({
|
||||
"label": site["label"],
|
||||
"visible": site.get("visible", True),
|
||||
"occupied_by": occupied.name if occupied is not None else None,
|
||||
"position": site["position"],
|
||||
"size": site["size"],
|
||||
"content_type": site["content_type"],
|
||||
})
|
||||
data["sites"] = sites_out
|
||||
return data
|
||||
|
||||
|
||||
class PRCXI9300Container(Plate):
|
||||
class PRCXI9300Container(Container):
|
||||
"""PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。
|
||||
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
@@ -116,11 +200,10 @@ class PRCXI9300Container(Plate):
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str,
|
||||
ordering: collections.OrderedDict,
|
||||
model: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, model=model)
|
||||
self._unilabos_state = {}
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
@@ -551,7 +634,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deck: Deck,
|
||||
deck: PRCXI9300Deck,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float,
|
||||
@@ -565,16 +648,16 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
is_9320=False,
|
||||
):
|
||||
tablets_info = []
|
||||
count = 0
|
||||
for child in deck.children:
|
||||
if child.children:
|
||||
if "Material" in child.children[0]._unilabos_state:
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]
|
||||
)
|
||||
for site_id in range(len(deck.sites)):
|
||||
child = deck._get_site_resource(site_id)
|
||||
# 如果放其他类型的物料,是不可以的
|
||||
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
|
||||
number = site_id + 1
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
|
||||
)
|
||||
)
|
||||
if is_9320:
|
||||
print("当前设备是9320")
|
||||
# 始终初始化 step_mode 属性
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
# -*- 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()
|
||||
@@ -623,119 +623,6 @@ 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)
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
# -*- 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")
|
||||
0
unilabos/devices/transport/__init__.py
Normal file
0
unilabos/devices/transport/__init__.py
Normal file
127
unilabos/devices/transport/agv_workstation.py
Normal file
127
unilabos/devices/transport/agv_workstation.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
AGV 通用转运工站 Driver
|
||||
|
||||
继承 WorkstationBase,通过 WorkstationNodeCreator 自动获得 ROS2WorkstationNode 能力。
|
||||
Warehouse 作为 children 中的资源节点,由 attach_resource() 自动注册到 resource_tracker。
|
||||
deck=None,不使用 PLR Deck 抽象。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.resources.warehouse import WareHouse
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
class AGVTransportStation(WorkstationBase):
|
||||
"""通用 AGV 转运工站
|
||||
|
||||
初始化链路(零框架改动):
|
||||
ROS2DeviceNode.__init__():
|
||||
issubclass(AGVTransportStation, WorkstationBase) → True
|
||||
→ WorkstationNodeCreator.create_instance(data):
|
||||
data["deck"] = None
|
||||
→ DeviceClassCreator.create_instance(data) → AGVTransportStation(deck=None, ...)
|
||||
→ attach_resource(): children 中 type="warehouse" → resource_tracker.add_resource(wh)
|
||||
→ ROS2WorkstationNode(protocol_type=[...], children=[nav, arm], ...)
|
||||
→ driver.post_init(ros_node):
|
||||
self.carrier 从 resource_tracker 中获取 WareHouse
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deck: Optional[Deck] = None,
|
||||
children: Optional[List[Any]] = None,
|
||||
route_table: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
device_roles: Optional[Dict[str, str]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=None, **kwargs)
|
||||
self.route_table: Dict[str, Dict[str, str]] = route_table or {}
|
||||
self.device_roles: Dict[str, str] = device_roles or {}
|
||||
|
||||
# ============ 载具 (Warehouse) ============
|
||||
|
||||
@property
|
||||
def carrier(self) -> Optional[WareHouse]:
|
||||
"""从 resource_tracker 中找到 AGV 载具 Warehouse"""
|
||||
if not hasattr(self, "_ros_node"):
|
||||
return None
|
||||
for res in self._ros_node.resource_tracker.resources:
|
||||
if isinstance(res, WareHouse):
|
||||
return res
|
||||
return None
|
||||
|
||||
@property
|
||||
def capacity(self) -> int:
|
||||
"""AGV 载具总容量(slot 数)"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return 0
|
||||
return wh.num_items_x * wh.num_items_y * wh.num_items_z
|
||||
|
||||
@property
|
||||
def free_slots(self) -> List[str]:
|
||||
"""返回当前空闲 slot 名称列表"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return []
|
||||
ordering = getattr(wh, "_ordering", {})
|
||||
return [name for name, site in ordering.items() if site.resource is None]
|
||||
|
||||
@property
|
||||
def occupied_slots(self) -> Dict[str, Any]:
|
||||
"""返回已占用的 slot → Resource 映射"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return {}
|
||||
ordering = getattr(wh, "_ordering", {})
|
||||
return {name: site.resource for name, site in ordering.items() if site.resource is not None}
|
||||
|
||||
# ============ 路由查询 ============
|
||||
|
||||
def resolve_route(self, from_station: str, to_station: str) -> Dict[str, str]:
|
||||
"""查询路由表,返回导航和机械臂指令
|
||||
|
||||
Args:
|
||||
from_station: 来源工站 ID
|
||||
to_station: 目标工站 ID
|
||||
|
||||
Returns:
|
||||
{"nav_command": "...", "arm_pick": "...", "arm_place": "..."}
|
||||
|
||||
Raises:
|
||||
KeyError: 路由表中未找到对应路线
|
||||
"""
|
||||
route_key = f"{from_station}->{to_station}"
|
||||
if route_key not in self.route_table:
|
||||
raise KeyError(f"路由表中未找到路线: {route_key}")
|
||||
return self.route_table[route_key]
|
||||
|
||||
def get_device_id(self, role: str) -> str:
|
||||
"""获取子设备 ID
|
||||
|
||||
Args:
|
||||
role: 设备角色,如 "navigator", "arm"
|
||||
|
||||
Returns:
|
||||
设备 ID 字符串
|
||||
|
||||
Raises:
|
||||
KeyError: 未配置该角色的设备
|
||||
"""
|
||||
if role not in self.device_roles:
|
||||
raise KeyError(f"未配置设备角色: {role},当前已配置: {list(self.device_roles.keys())}")
|
||||
return self.device_roles[role]
|
||||
|
||||
# ============ 生命周期 ============
|
||||
|
||||
def post_init(self, ros_node) -> None:
|
||||
super().post_init(ros_node)
|
||||
wh = self.carrier
|
||||
if wh is not None:
|
||||
logger.info(f"AGV {ros_node.device_id} 载具已就绪: {wh.name}, 容量={self.capacity}")
|
||||
else:
|
||||
logger.warning(f"AGV {ros_node.device_id} 未发现 Warehouse 载具资源")
|
||||
88
unilabos/devices/virtual/virtual_sample_demo.py
Normal file
88
unilabos/devices/virtual/virtual_sample_demo.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class VirtualSampleDemo:
|
||||
"""虚拟样品追踪演示设备,提供两种典型返回模式:
|
||||
- measure_samples: 等长输入输出 (前端按 index 自动对齐)
|
||||
- split_and_measure: 输出比输入长,附带 samples 列标注归属
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
if device_id is None and "id" in kwargs:
|
||||
device_id = kwargs.pop("id")
|
||||
if config is None and "config" in kwargs:
|
||||
config = kwargs.pop("config")
|
||||
|
||||
self.device_id = device_id or "unknown_sample_demo"
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}")
|
||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action 1: 等长输入输出,无 samples 列
|
||||
# ------------------------------------------------------------------
|
||||
async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]:
|
||||
"""模拟光度测量。absorbance = concentration * 0.05 + noise
|
||||
|
||||
入参和出参 list 长度相等,前端按 index 自动对齐。
|
||||
"""
|
||||
self.logger.info(f"measure_samples: concentrations={concentrations}")
|
||||
absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations]
|
||||
return {"concentrations": concentrations, "absorbance": absorbance}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action 2: 输出比输入长,带 samples 列
|
||||
# ------------------------------------------------------------------
|
||||
async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]:
|
||||
"""将每个样品均分为 split_count 份后逐份测量。
|
||||
|
||||
返回的 list 长度 = len(volumes) * split_count,
|
||||
附带 samples 列标注每行属于第几个输入样品 (0-based index)。
|
||||
"""
|
||||
self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}")
|
||||
out_volumes: List[float] = []
|
||||
readings: List[float] = []
|
||||
samples: List[int] = []
|
||||
|
||||
for idx, vol in enumerate(volumes):
|
||||
split_vol = round(vol / split_count, 2)
|
||||
for _ in range(split_count):
|
||||
out_volumes.append(split_vol)
|
||||
readings.append(round(random.uniform(0.1, 1.0), 4))
|
||||
samples.append(idx)
|
||||
|
||||
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action 3: 入参和出参都带 samples 列(不等长)
|
||||
# ------------------------------------------------------------------
|
||||
async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]:
|
||||
"""对 split_and_measure 的输出做二次分析。
|
||||
|
||||
入参 readings/samples 长度相同但 > 原始样品数,
|
||||
出参同样带 samples 列,长度与入参一致。
|
||||
"""
|
||||
self.logger.info(f"analyze_readings: readings={readings}, samples={samples}")
|
||||
scores: List[float] = []
|
||||
passed: List[bool] = []
|
||||
threshold = 0.4
|
||||
|
||||
for r in readings:
|
||||
score = round(r * 100 + random.gauss(0, 2), 2)
|
||||
scores.append(score)
|
||||
passed.append(r >= threshold)
|
||||
|
||||
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 状态属性
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Idle")
|
||||
@@ -1,15 +1,15 @@
|
||||
"""
|
||||
Virtual Workbench Device - 模拟工作台设备
|
||||
包含:
|
||||
包含:
|
||||
- 1个机械臂 (每次操作3s, 独占锁)
|
||||
- 3个加热台 (每次加热10s, 可并行)
|
||||
|
||||
工作流程:
|
||||
1. A1-A5 物料同时启动,竞争机械臂
|
||||
工作流程:
|
||||
1. A1-A5 物料同时启动, 竞争机械臂
|
||||
2. 机械臂将物料移动到空闲加热台
|
||||
3. 加热完成后,机械臂将物料移动到C1-C5
|
||||
3. 加热完成后, 机械臂将物料移动到C1-C5
|
||||
|
||||
注意:调用来自线程池,使用 threading.Lock 进行同步
|
||||
注意: 调用来自线程池, 使用 threading.Lock 进行同步
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -21,9 +21,11 @@ from threading import Lock, RLock
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.utils.decorator import not_action, always_free
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, RETURN_UNILABOS_SAMPLES
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
@@ -57,6 +59,8 @@ class MoveToOutputResult(TypedDict):
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
output_position: str
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
@@ -81,9 +85,9 @@ class HeatingStationState(Enum):
|
||||
"""加热台状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
OCCUPIED = "occupied" # 已放置物料,等待加热
|
||||
OCCUPIED = "occupied" # 已放置物料, 等待加热
|
||||
HEATING = "heating" # 加热中
|
||||
COMPLETED = "completed" # 加热完成,等待取走
|
||||
COMPLETED = "completed" # 加热完成, 等待取走
|
||||
|
||||
|
||||
class ArmState(Enum):
|
||||
@@ -105,19 +109,24 @@ class HeatingStation:
|
||||
heating_progress: float = 0.0
|
||||
|
||||
|
||||
@device(
|
||||
id="virtual_workbench",
|
||||
category=["virtual_device"],
|
||||
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
||||
)
|
||||
class VirtualWorkbench:
|
||||
"""
|
||||
Virtual Workbench Device - 虚拟工作台设备
|
||||
|
||||
模拟一个包含1个机械臂和3个加热台的工作站
|
||||
- 机械臂操作耗时3秒,同一时间只能执行一个操作
|
||||
- 加热台加热耗时10秒,3个加热台可并行工作
|
||||
- 机械臂操作耗时3秒, 同一时间只能执行一个操作
|
||||
- 加热台加热耗时10秒, 3个加热台可并行工作
|
||||
|
||||
工作流:
|
||||
1. 物料A1-A5并发启动(线程池),竞争机械臂使用权
|
||||
2. 获取机械臂后,查找空闲加热台
|
||||
3. 机械臂将物料放入加热台,开始加热
|
||||
4. 加热完成后,机械臂将物料移动到目标位置Cn
|
||||
1. 物料A1-A5并发启动(线程池), 竞争机械臂使用权
|
||||
2. 获取机械臂后, 查找空闲加热台
|
||||
3. 机械臂将物料放入加热台, 开始加热
|
||||
4. 加热完成后, 机械臂将物料移动到目标位置Cn
|
||||
"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
@@ -145,19 +154,19 @@ class VirtualWorkbench:
|
||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
||||
|
||||
# 机械臂状态和锁 (使用threading.Lock)
|
||||
# 机械臂状态和锁
|
||||
self._arm_lock = Lock()
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task: Optional[str] = None
|
||||
|
||||
# 加热台状态 (station_id -> HeatingStation) - 立即初始化,不依赖initialize()
|
||||
# 加热台状态
|
||||
self._heating_stations: Dict[int, HeatingStation] = {
|
||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
}
|
||||
self._stations_lock = RLock() # 可重入锁,保护加热台状态
|
||||
self._stations_lock = RLock()
|
||||
|
||||
# 任务追踪
|
||||
self._active_tasks: Dict[str, Dict[str, Any]] = {} # material_id -> task_info
|
||||
self._active_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
self._tasks_lock = Lock()
|
||||
|
||||
# 处理其他kwargs参数
|
||||
@@ -183,7 +192,6 @@ class VirtualWorkbench:
|
||||
"""初始化虚拟工作台"""
|
||||
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
||||
|
||||
# 重置加热台状态 (已在__init__中创建,这里重置为初始状态)
|
||||
with self._stations_lock:
|
||||
for station in self._heating_stations.values():
|
||||
station.state = HeatingStationState.IDLE
|
||||
@@ -191,7 +199,6 @@ class VirtualWorkbench:
|
||||
station.material_number = None
|
||||
station.heating_progress = 0.0
|
||||
|
||||
# 初始化状态
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Ready",
|
||||
@@ -257,11 +264,7 @@ class VirtualWorkbench:
|
||||
self.data["message"] = message
|
||||
|
||||
def _find_available_heating_station(self) -> Optional[int]:
|
||||
"""查找空闲的加热台
|
||||
|
||||
Returns:
|
||||
空闲加热台ID,如果没有则返回None
|
||||
"""
|
||||
"""查找空闲的加热台"""
|
||||
with self._stations_lock:
|
||||
for station_id, station in self._heating_stations.items():
|
||||
if station.state == HeatingStationState.IDLE:
|
||||
@@ -269,23 +272,12 @@ class VirtualWorkbench:
|
||||
return None
|
||||
|
||||
def _acquire_arm(self, task_description: str) -> bool:
|
||||
"""获取机械臂使用权(阻塞直到获取)
|
||||
|
||||
Args:
|
||||
task_description: 任务描述,用于日志
|
||||
|
||||
Returns:
|
||||
是否成功获取
|
||||
"""
|
||||
"""获取机械臂使用权(阻塞直到获取)"""
|
||||
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
||||
|
||||
# 阻塞等待获取锁
|
||||
self._arm_lock.acquire()
|
||||
|
||||
self._arm_state = ArmState.BUSY
|
||||
self._arm_current_task = task_description
|
||||
self._update_data_status(f"机械臂执行: {task_description}")
|
||||
|
||||
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
||||
return True
|
||||
|
||||
@@ -298,6 +290,22 @@ class VirtualWorkbench:
|
||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||
handles=[
|
||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def prepare_materials(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
@@ -306,19 +314,14 @@ class VirtualWorkbench:
|
||||
"""
|
||||
批量准备物料 - 虚拟起始节点
|
||||
|
||||
作为工作流的起始节点,生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5),分别对应实验1~5。
|
||||
|
||||
Args:
|
||||
count: 待生成的物料数量,默认5 (生成 A1-A5)
|
||||
|
||||
Returns:
|
||||
PrepareMaterialsResult: 包含 material_1 ~ material_5 用于传递给 move_to_heating_station
|
||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
||||
"""
|
||||
# 生成物料列表 A1 - A{count}
|
||||
materials = [i for i in range(1, count + 1)]
|
||||
|
||||
self.logger.info(f"[准备物料] 生成 {count} 个物料: " f"A1-A{count} -> material_1~material_{count}")
|
||||
self.logger.info(
|
||||
f"[准备物料] 生成 {count} 个物料: A1-A{count} -> material_1~material_{count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -329,9 +332,28 @@ class VirtualWorkbench:
|
||||
"material_4": materials[3] if len(materials) > 3 else 0,
|
||||
"material_5": materials[4] if len(materials) > 4 else 0,
|
||||
"message": f"已准备 {count} 个物料: A1-A{count}",
|
||||
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
||||
handles=[
|
||||
ActionInputHandle(key="material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def move_to_heating_station(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
@@ -340,20 +362,12 @@ class VirtualWorkbench:
|
||||
"""
|
||||
将物料从An位置移动到加热台
|
||||
|
||||
多线程并发调用时,会竞争机械臂使用权,并自动查找空闲加热台
|
||||
|
||||
Args:
|
||||
material_number: 物料编号 (1-5)
|
||||
|
||||
Returns:
|
||||
MoveToHeatingStationResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
||||
"""
|
||||
# 根据物料编号生成物料ID
|
||||
material_id = f"A{material_number}"
|
||||
task_desc = f"移动{material_id}到加热台"
|
||||
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
||||
|
||||
# 记录任务
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id] = {
|
||||
"status": "waiting_for_arm",
|
||||
@@ -361,33 +375,27 @@ class VirtualWorkbench:
|
||||
}
|
||||
|
||||
try:
|
||||
# 步骤1: 等待获取机械臂使用权(竞争)
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
# 步骤2: 查找空闲加热台
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "finding_station"
|
||||
station_id = None
|
||||
|
||||
# 循环等待直到找到空闲加热台
|
||||
while station_id is None:
|
||||
station_id = self._find_available_heating_station()
|
||||
if station_id is None:
|
||||
self.logger.info(f"[{material_id}] 没有空闲加热台,等待中...")
|
||||
# 释放机械臂,等待后重试
|
||||
self.logger.info(f"[{material_id}] 没有空闲加热台, 等待中...")
|
||||
self._release_arm()
|
||||
time.sleep(0.5)
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
# 步骤3: 占用加热台 - 立即标记为OCCUPIED,防止其他任务选择同一加热台
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
||||
self._heating_stations[station_id].current_material = material_id
|
||||
self._heating_stations[station_id].material_number = material_number
|
||||
|
||||
# 步骤4: 模拟机械臂移动操作 (3秒)
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving"
|
||||
self._active_tasks[material_id]["assigned_station"] = station_id
|
||||
@@ -395,11 +403,11 @@ class VirtualWorkbench:
|
||||
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
# 步骤5: 放入加热台完成
|
||||
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
||||
self.logger.info(f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)"
|
||||
)
|
||||
|
||||
# 释放机械臂
|
||||
self._release_arm()
|
||||
|
||||
with self._tasks_lock:
|
||||
@@ -412,8 +420,16 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"{material_id}已成功移动到加热台{station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -427,11 +443,33 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@always_free
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
always_free=True,
|
||||
description="启动指定加热台的加热程序",
|
||||
handles=[
|
||||
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
||||
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
||||
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def start_heating(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
@@ -440,13 +478,6 @@ class VirtualWorkbench:
|
||||
) -> StartHeatingResult:
|
||||
"""
|
||||
启动指定加热台的加热程序
|
||||
|
||||
Args:
|
||||
station_id: 加热台ID (1-3),从 move_to_heating_station 的 handle 传入
|
||||
material_number: 物料编号,从 move_to_heating_station 的 handle 传入
|
||||
|
||||
Returns:
|
||||
StartHeatingResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||
"""
|
||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||
|
||||
@@ -458,8 +489,16 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -473,8 +512,16 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
if station.state == HeatingStationState.HEATING:
|
||||
@@ -485,13 +532,20 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}已经在加热中",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
material_id = station.current_material
|
||||
|
||||
# 开始加热
|
||||
station.state = HeatingStationState.HEATING
|
||||
station.heating_start_time = time.time()
|
||||
station.heating_progress = 0.0
|
||||
@@ -502,7 +556,6 @@ class VirtualWorkbench:
|
||||
|
||||
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
||||
|
||||
# 打印当前所有正在加热的台位
|
||||
with self._stations_lock:
|
||||
heating_list = [
|
||||
f"加热台{sid}:{s.current_material}"
|
||||
@@ -511,7 +564,6 @@ class VirtualWorkbench:
|
||||
]
|
||||
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
||||
|
||||
# 模拟加热过程
|
||||
start_time = time.time()
|
||||
last_countdown_log = start_time
|
||||
while True:
|
||||
@@ -524,7 +576,6 @@ class VirtualWorkbench:
|
||||
|
||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||
|
||||
# 每5秒打印一次倒计时
|
||||
if time.time() - last_countdown_log >= 5.0:
|
||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
||||
last_countdown_log = time.time()
|
||||
@@ -534,7 +585,6 @@ class VirtualWorkbench:
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
# 加热完成
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
||||
self._heating_stations[station_id].heating_progress = 100.0
|
||||
@@ -553,10 +603,28 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}加热完成",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="将物料从加热台移动到输出位置Cn",
|
||||
handles=[
|
||||
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
],
|
||||
)
|
||||
def move_to_output(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
@@ -565,15 +633,8 @@ class VirtualWorkbench:
|
||||
) -> MoveToOutputResult:
|
||||
"""
|
||||
将物料从加热台移动到输出位置Cn
|
||||
|
||||
Args:
|
||||
station_id: 加热台ID (1-3),从 start_heating 的 handle 传入
|
||||
material_number: 物料编号,从 start_heating 的 handle 传入,用于确定输出位置 Cn
|
||||
|
||||
Returns:
|
||||
MoveToOutputResult: 包含执行结果
|
||||
"""
|
||||
output_number = material_number # 物料编号决定输出位置
|
||||
output_number = material_number
|
||||
|
||||
if station_id not in self._heating_stations:
|
||||
return {
|
||||
@@ -583,8 +644,16 @@ class VirtualWorkbench:
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -599,8 +668,16 @@ class VirtualWorkbench:
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
if station.state != HeatingStationState.COMPLETED:
|
||||
@@ -611,8 +688,16 @@ class VirtualWorkbench:
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
output_position = f"C{output_number}"
|
||||
@@ -624,18 +709,17 @@ class VirtualWorkbench:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
||||
|
||||
# 获取机械臂
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
||||
|
||||
# 模拟机械臂操作 (3秒)
|
||||
self.logger.info(f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}...")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}..."
|
||||
)
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
# 清空加热台
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
||||
self._heating_stations[station_id].current_material = None
|
||||
@@ -643,17 +727,17 @@ class VirtualWorkbench:
|
||||
self._heating_stations[station_id].heating_progress = 0.0
|
||||
self._heating_stations[station_id].heating_start_time = None
|
||||
|
||||
# 释放机械臂
|
||||
self._release_arm()
|
||||
|
||||
# 任务完成
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "completed"
|
||||
self._active_tasks[material_id]["end_time"] = time.time()
|
||||
|
||||
self._update_data_status(f"{material_id}已移动到{output_position}")
|
||||
self.logger.info(f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -662,8 +746,17 @@ class VirtualWorkbench:
|
||||
"output_position": output_position,
|
||||
"message": f"{material_id}已成功移动到{output_position}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str)
|
||||
else (content.serialize() if content is not None else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -677,83 +770,105 @@ class VirtualWorkbench:
|
||||
"output_position": output_position,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
# ============ 状态属性 ============
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Unknown")
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def arm_state(self) -> str:
|
||||
return self._arm_state.value
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def arm_current_task(self) -> str:
|
||||
return self._arm_current_task or ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def active_tasks_count(self) -> int:
|
||||
with self._tasks_lock:
|
||||
return len(self._active_tasks)
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def message(self) -> str:
|
||||
return self.data.get("message", "")
|
||||
|
||||
@@ -258,7 +258,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||
|
||||
# 第1步:从配置中获取仓库配置
|
||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
# 确定目标仓库名称
|
||||
parent_name = None
|
||||
|
||||
@@ -197,6 +197,28 @@ class WorkstationBase(ABC):
|
||||
self._ros_node = workstation_node
|
||||
logger.info(f"工作站 {self._ros_node.device_id} 关联协议节点")
|
||||
|
||||
# ============ 物料转运回调 ============
|
||||
|
||||
def resource_tree_batch_transfer(
|
||||
self,
|
||||
transfers: list,
|
||||
old_parents: list,
|
||||
new_parents: list,
|
||||
) -> None:
|
||||
"""批量物料转运完成后的回调,供子类重写
|
||||
|
||||
默认实现:逐个调用 resource_tree_transfer(如存在)。
|
||||
|
||||
Args:
|
||||
transfers: 转移列表,每项包含 resource, from_parent, to_parent, to_site 等
|
||||
old_parents: 每个物料转移前的原父节点
|
||||
new_parents: 每个物料转移后的新父节点
|
||||
"""
|
||||
func = getattr(self, "resource_tree_transfer", None)
|
||||
if callable(func):
|
||||
for t, old_parent, new_parent in zip(transfers, old_parents, new_parents):
|
||||
func(old_parent, t["resource"], new_parent)
|
||||
|
||||
# ============ 设备操作接口 ============
|
||||
|
||||
def call_device_method(self, method: str, *args, **kwargs) -> Any:
|
||||
|
||||
@@ -217,6 +217,24 @@ class AGVTransferProtocol(BaseModel):
|
||||
from_repo_position: str
|
||||
to_repo_position: str
|
||||
|
||||
|
||||
class BatchTransferItem(BaseModel):
|
||||
"""批量转运中的单个物料条目"""
|
||||
resource_uuid: str = ""
|
||||
resource_id: str = ""
|
||||
from_position: str
|
||||
to_position: str
|
||||
|
||||
|
||||
class BatchTransferProtocol(BaseModel):
|
||||
"""批量物料转运协议 — 支持多物料一次性从来源工站转运到目标工站"""
|
||||
from_repo: dict
|
||||
to_repo: dict
|
||||
transfer_resources: list # list[Resource dict],被转运的物料
|
||||
from_positions: list # list[str],来源 slot 位置(与 transfer_resources 平行)
|
||||
to_positions: list # list[str],目标 slot 位置(与 transfer_resources 平行)
|
||||
|
||||
|
||||
#=============新添加的新的协议================
|
||||
class AddProtocol(BaseModel):
|
||||
vessel: dict
|
||||
@@ -629,15 +647,16 @@ class HydrogenateProtocol(BaseModel):
|
||||
vessel: dict = Field(..., description="反应容器")
|
||||
|
||||
__all__ = [
|
||||
"Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol",
|
||||
"EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol",
|
||||
"CentrifugeProtocol", "AddProtocol", "FilterProtocol",
|
||||
"Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol",
|
||||
"EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol",
|
||||
"BatchTransferItem", "BatchTransferProtocol",
|
||||
"CentrifugeProtocol", "AddProtocol", "FilterProtocol",
|
||||
"HeatChillProtocol",
|
||||
"HeatChillStartProtocol", "HeatChillStopProtocol",
|
||||
"StirProtocol", "StartStirProtocol", "StopStirProtocol",
|
||||
"TransferProtocol", "CleanVesselProtocol", "DissolveProtocol",
|
||||
"StirProtocol", "StartStirProtocol", "StopStirProtocol",
|
||||
"TransferProtocol", "CleanVesselProtocol", "DissolveProtocol",
|
||||
"FilterThroughProtocol", "RunColumnProtocol", "WashSolidProtocol",
|
||||
"AdjustPHProtocol", "ResetHandlingProtocol", "DryProtocol",
|
||||
"AdjustPHProtocol", "ResetHandlingProtocol", "DryProtocol",
|
||||
"RecrystallizeProtocol", "HydrogenateProtocol"
|
||||
]
|
||||
# End Protocols
|
||||
|
||||
1044
unilabos/registry/ast_registry_scanner.py
Normal file
1044
unilabos/registry/ast_registry_scanner.py
Normal file
File diff suppressed because it is too large
Load Diff
658
unilabos/registry/decorators.py
Normal file
658
unilabos/registry/decorators.py
Normal file
@@ -0,0 +1,658 @@
|
||||
"""
|
||||
装饰器注册表系统
|
||||
|
||||
通过 @device, @action, @resource 装饰器替代 YAML 配置文件来定义设备/动作/资源注册表信息。
|
||||
|
||||
Usage:
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, resource,
|
||||
InputHandle, OutputHandle,
|
||||
ActionInputHandle, ActionOutputHandle,
|
||||
HardwareInterface, Side, DataSource, NodeType,
|
||||
)
|
||||
|
||||
@device(
|
||||
id="solenoid_valve.mock",
|
||||
category=["pump_and_valve"],
|
||||
description="模拟电磁阀设备",
|
||||
handles=[
|
||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH),
|
||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH),
|
||||
],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="hardware_interface",
|
||||
read="send_command",
|
||||
write="send_command",
|
||||
),
|
||||
)
|
||||
class SolenoidValveMock:
|
||||
@action(action_type=EmptyIn)
|
||||
def close(self):
|
||||
...
|
||||
|
||||
@action(
|
||||
handles=[
|
||||
ActionInputHandle(key="in", data_type="fluid", label="in"),
|
||||
ActionOutputHandle(key="out", data_type="fluid", label="out"),
|
||||
],
|
||||
)
|
||||
def set_valve_position(self, position):
|
||||
...
|
||||
|
||||
# 无 @action 装饰器 => auto- 前缀动作
|
||||
def is_open(self):
|
||||
...
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 枚举
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Side(str, Enum):
|
||||
"""UI 上 Handle 的显示位置"""
|
||||
|
||||
NORTH = "NORTH"
|
||||
SOUTH = "SOUTH"
|
||||
EAST = "EAST"
|
||||
WEST = "WEST"
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
"""Handle 的数据来源"""
|
||||
|
||||
HANDLE = "handle" # 从上游 handle 获取数据 (用于 InputHandle)
|
||||
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
|
||||
|
||||
|
||||
class NodeType(str, Enum):
|
||||
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
|
||||
|
||||
ILAB = "ILab"
|
||||
MANUAL_CONFIRM = "manual_confirm"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DeviceHandleBase(BaseModel):
|
||||
"""设备/资源端口基类 (内部使用)"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
key: str = Field(serialization_alias="handler_key")
|
||||
data_type: str
|
||||
label: str
|
||||
side: Optional[Side] = None
|
||||
data_key: Optional[str] = None
|
||||
data_source: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# 子类覆盖
|
||||
io_type: str = ""
|
||||
|
||||
def to_registry_dict(self) -> Dict[str, Any]:
|
||||
return self.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class InputHandle(_DeviceHandleBase):
|
||||
"""
|
||||
输入端口 (io_type="target"), 用于 @device / @resource handles
|
||||
|
||||
Example:
|
||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH)
|
||||
"""
|
||||
|
||||
io_type: str = "target"
|
||||
|
||||
|
||||
class OutputHandle(_DeviceHandleBase):
|
||||
"""
|
||||
输出端口 (io_type="source"), 用于 @device / @resource handles
|
||||
|
||||
Example:
|
||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH)
|
||||
"""
|
||||
|
||||
io_type: str = "source"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action Handle (动作级别端口, 序列化时不含 io_type, 按类型自动分组)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ActionHandleBase(BaseModel):
|
||||
"""动作端口基类 (内部使用)"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
key: str = Field(serialization_alias="handler_key")
|
||||
data_type: str
|
||||
label: str
|
||||
side: Optional[Side] = None
|
||||
data_key: Optional[str] = None
|
||||
data_source: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
io_type: Optional[str] = None # source/sink (dataflow) or target/source (device-style)
|
||||
|
||||
def to_registry_dict(self) -> Dict[str, Any]:
|
||||
return self.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class ActionInputHandle(_ActionHandleBase):
|
||||
"""
|
||||
动作输入端口, 用于 @action handles, 序列化后归入 "input" 组
|
||||
|
||||
Example:
|
||||
ActionInputHandle(
|
||||
key="material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source="handle",
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActionOutputHandle(_ActionHandleBase):
|
||||
"""
|
||||
动作输出端口, 用于 @action handles, 序列化后归入 "output" 组
|
||||
|
||||
Example:
|
||||
ActionOutputHandle(
|
||||
key="station_output", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source="executor",
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HardwareInterface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HardwareInterface(BaseModel):
|
||||
"""
|
||||
硬件通信接口定义
|
||||
|
||||
描述设备与底层硬件通信的方式 (串口、Modbus 等)。
|
||||
|
||||
Example:
|
||||
HardwareInterface(name="hardware_interface", read="send_command", write="send_command")
|
||||
"""
|
||||
|
||||
name: str
|
||||
read: Optional[str] = None
|
||||
write: Optional[str] = None
|
||||
extra_info: Optional[List[str]] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 全局注册表 -- 记录所有被装饰器标记的类/函数
|
||||
# ---------------------------------------------------------------------------
|
||||
_registered_devices: Dict[str, type] = {} # device_id -> class
|
||||
_registered_resources: Dict[str, Any] = {} # resource_id -> class or function
|
||||
|
||||
|
||||
def _device_handles_to_list(
|
||||
handles: Optional[List[_DeviceHandleBase]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将设备/资源 Handle 列表序列化为字典列表 (含 io_type)"""
|
||||
if handles is None:
|
||||
return []
|
||||
return [h.to_registry_dict() for h in handles]
|
||||
|
||||
|
||||
def _action_handles_to_dict(
|
||||
handles: Optional[List[_ActionHandleBase]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将动作 Handle 列表序列化为 {"input": [...], "output": [...]} 格式。
|
||||
|
||||
ActionInputHandle => "input", ActionOutputHandle => "output"
|
||||
"""
|
||||
if handles is None:
|
||||
return {}
|
||||
input_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionInputHandle)]
|
||||
output_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionOutputHandle)]
|
||||
result: Dict[str, Any] = {}
|
||||
if input_list:
|
||||
result["input"] = input_list
|
||||
if output_list:
|
||||
result["output"] = output_list
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @device 类装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
def device(
|
||||
id: Optional[str] = None,
|
||||
ids: Optional[List[str]] = None,
|
||||
id_meta: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
category: Optional[List[str]] = None,
|
||||
description: str = "",
|
||||
display_name: str = "",
|
||||
icon: str = "",
|
||||
version: str = "1.0.0",
|
||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
||||
model: Optional[Dict[str, Any]] = None,
|
||||
device_type: str = "python",
|
||||
hardware_interface: Optional[HardwareInterface] = None,
|
||||
):
|
||||
"""
|
||||
设备类装饰器
|
||||
|
||||
将类标记为一个 UniLab-OS 设备,并附加注册表元数据。
|
||||
|
||||
支持两种模式:
|
||||
1. 单设备: id="xxx", category=[...]
|
||||
2. 多设备: ids=["id1","id2"], id_meta={"id1":{handles:[...]}, "id2":{...}}
|
||||
|
||||
Args:
|
||||
id: 单设备时的注册表唯一标识
|
||||
ids: 多设备时的 id 列表,与 id_meta 配合使用
|
||||
id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model)
|
||||
category: 设备分类标签列表 (必填)
|
||||
description: 设备描述
|
||||
display_name: 人类可读的设备显示名称,缺失时默认使用 id
|
||||
icon: 图标路径
|
||||
version: 版本号
|
||||
handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用)
|
||||
model: 可选的 3D 模型配置
|
||||
device_type: 设备实现类型 ("python" / "ros2")
|
||||
hardware_interface: 硬件通信接口 (HardwareInterface)
|
||||
"""
|
||||
# Resolve device ids
|
||||
if ids is not None:
|
||||
device_ids = list(ids)
|
||||
if not device_ids:
|
||||
raise ValueError("@device ids 不能为空")
|
||||
id_meta = id_meta or {}
|
||||
elif id is not None:
|
||||
device_ids = [id]
|
||||
id_meta = {}
|
||||
else:
|
||||
raise ValueError("@device 必须提供 id 或 ids")
|
||||
|
||||
if category is None:
|
||||
raise ValueError("@device category 必填")
|
||||
|
||||
base_meta = {
|
||||
"category": category,
|
||||
"description": description,
|
||||
"display_name": display_name,
|
||||
"icon": icon,
|
||||
"version": version,
|
||||
"handles": _device_handles_to_list(handles),
|
||||
"model": model,
|
||||
"device_type": device_type,
|
||||
"hardware_interface": (hardware_interface.model_dump(exclude_none=True) if hardware_interface else None),
|
||||
}
|
||||
|
||||
def decorator(cls):
|
||||
cls._device_registry_meta = base_meta
|
||||
cls._device_registry_id_meta = id_meta
|
||||
cls._device_registry_ids = device_ids
|
||||
|
||||
for did in device_ids:
|
||||
if did in _registered_devices:
|
||||
raise ValueError(f"@device id 重复: '{did}' 已被 {_registered_devices[did]} 注册")
|
||||
_registered_devices[did] = cls
|
||||
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @action 方法装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 区分 "用户没传 action_type" 和 "用户传了 None"
|
||||
_ACTION_TYPE_UNSET = object()
|
||||
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def action(
|
||||
action_type: Any = _ACTION_TYPE_UNSET,
|
||||
goal: Optional[Dict[str, str]] = None,
|
||||
feedback: Optional[Dict[str, str]] = None,
|
||||
result: Optional[Dict[str, str]] = None,
|
||||
handles: Optional[List[_ActionHandleBase]] = None,
|
||||
goal_default: Optional[Dict[str, Any]] = None,
|
||||
placeholder_keys: Optional[Dict[str, str]] = None,
|
||||
always_free: bool = False,
|
||||
is_protocol: bool = False,
|
||||
description: str = "",
|
||||
auto_prefix: bool = False,
|
||||
parent: bool = False,
|
||||
node_type: Optional["NodeType"] = None,
|
||||
):
|
||||
"""
|
||||
动作方法装饰器
|
||||
|
||||
标记方法为注册表动作。有三种用法:
|
||||
1. @action(action_type=EmptyIn, ...) -- 非 auto, 使用指定 ROS Action 类型
|
||||
2. @action() -- 非 auto, UniLabJsonCommand (从方法签名生成 schema)
|
||||
3. 不加 @action -- auto- 前缀, UniLabJsonCommand
|
||||
|
||||
Protocol 用法:
|
||||
@action(action_type=Add, is_protocol=True)
|
||||
def AddProtocol(self): ...
|
||||
标记该动作为高级协议 (protocol),运行时通过 ROS Action 路由到
|
||||
protocol generator 执行。action_type 指向 unilabos_msgs 的 Action 类型。
|
||||
|
||||
Args:
|
||||
action_type: ROS Action 消息类型 (如 EmptyIn, SendCmd, HeatChill).
|
||||
不传/默认 = UniLabJsonCommand (非 auto).
|
||||
goal: Goal 字段映射 (ROS字段名 -> 设备参数名).
|
||||
protocol 模式下可留空,系统自动生成 identity 映射.
|
||||
feedback: Feedback 字段映射
|
||||
result: Result 字段映射
|
||||
handles: 动作端口列表 (ActionInputHandle / ActionOutputHandle)
|
||||
goal_default: Goal 字段默认值映射 (字段名 -> 默认值), 与自动生成的 goal_default 合并
|
||||
placeholder_keys: 参数占位符配置
|
||||
always_free: 是否为永久闲置动作 (不受排队限制)
|
||||
is_protocol: 是否为工作站协议 (protocol)。True 时运行时走 protocol generator 路径。
|
||||
description: 动作描述
|
||||
auto_prefix: 若为 True,动作名使用 auto-{method_name} 形式(与无 @action 时一致)
|
||||
parent: 若为 True,当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
|
||||
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
|
||||
不填写时不写入注册表。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
|
||||
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
|
||||
|
||||
meta = {
|
||||
"action_type": resolved_type,
|
||||
"goal": goal or {},
|
||||
"feedback": feedback or {},
|
||||
"result": result or {},
|
||||
"handles": _action_handles_to_dict(handles),
|
||||
"goal_default": goal_default or {},
|
||||
"placeholder_keys": placeholder_keys or {},
|
||||
"always_free": always_free,
|
||||
"is_protocol": is_protocol,
|
||||
"description": description,
|
||||
"auto_prefix": auto_prefix,
|
||||
"parent": parent,
|
||||
}
|
||||
if node_type is not None:
|
||||
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
||||
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
||||
|
||||
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
|
||||
if always_free:
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_action_meta(func) -> Optional[Dict[str, Any]]:
|
||||
"""获取方法上的 @action 装饰器元数据"""
|
||||
return getattr(func, "_action_registry_meta", None)
|
||||
|
||||
|
||||
def has_action_decorator(func) -> bool:
|
||||
"""检查函数是否带有 @action 装饰器"""
|
||||
return hasattr(func, "_action_registry_meta")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @resource 类/函数装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resource(
|
||||
id: str,
|
||||
category: List[str],
|
||||
description: str = "",
|
||||
icon: str = "",
|
||||
version: str = "1.0.0",
|
||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
||||
model: Optional[Dict[str, Any]] = None,
|
||||
class_type: str = "pylabrobot",
|
||||
):
|
||||
"""
|
||||
资源类/函数装饰器
|
||||
|
||||
将类或工厂函数标记为一个 UniLab-OS 资源,附加注册表元数据。
|
||||
|
||||
Args:
|
||||
id: 注册表唯一标识 (必填, 不可重复)
|
||||
category: 资源分类标签列表 (必填)
|
||||
description: 资源描述
|
||||
icon: 图标路径
|
||||
version: 版本号
|
||||
handles: 端口列表 (InputHandle / OutputHandle)
|
||||
model: 可选的 3D 模型配置
|
||||
class_type: 资源实现类型 ("python" / "pylabrobot" / "unilabos")
|
||||
"""
|
||||
|
||||
def decorator(obj):
|
||||
meta = {
|
||||
"resource_id": id,
|
||||
"category": category,
|
||||
"description": description,
|
||||
"icon": icon,
|
||||
"version": version,
|
||||
"handles": _device_handles_to_list(handles),
|
||||
"model": model,
|
||||
"class_type": class_type,
|
||||
}
|
||||
obj._resource_registry_meta = meta
|
||||
|
||||
if id in _registered_resources:
|
||||
raise ValueError(f"@resource id 重复: '{id}' 已被 {_registered_resources[id]} 注册")
|
||||
_registered_resources[id] = obj
|
||||
|
||||
return obj
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取类上的 @device 装饰器元数据。
|
||||
|
||||
当 device_id 存在且类使用 ids+id_meta 时,返回合并后的 meta
|
||||
(base_meta 与 id_meta[device_id] 深度合并)。
|
||||
"""
|
||||
base = getattr(cls, "_device_registry_meta", None)
|
||||
if base is None:
|
||||
return None
|
||||
id_meta = getattr(cls, "_device_registry_id_meta", None) or {}
|
||||
if device_id is None or device_id not in id_meta:
|
||||
result = dict(base)
|
||||
ids = getattr(cls, "_device_registry_ids", None)
|
||||
result["device_id"] = device_id if device_id is not None else (ids[0] if ids else None)
|
||||
return result
|
||||
|
||||
overrides = id_meta[device_id]
|
||||
result = dict(base)
|
||||
result["device_id"] = device_id
|
||||
for key in ["handles", "description", "icon", "model"]:
|
||||
if key in overrides:
|
||||
val = overrides[key]
|
||||
if key == "handles" and isinstance(val, list):
|
||||
# handles 必须是 Handle 对象列表
|
||||
result[key] = [h.to_registry_dict() for h in val]
|
||||
else:
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def get_resource_meta(obj) -> Optional[Dict[str, Any]]:
|
||||
"""获取对象上的 @resource 装饰器元数据"""
|
||||
return getattr(obj, "_resource_registry_meta", None)
|
||||
|
||||
|
||||
def get_all_registered_devices() -> Dict[str, type]:
|
||||
"""获取所有已注册的设备类"""
|
||||
return _registered_devices.copy()
|
||||
|
||||
|
||||
def get_all_registered_resources() -> Dict[str, Any]:
|
||||
"""获取所有已注册的资源"""
|
||||
return _registered_resources.copy()
|
||||
|
||||
|
||||
def clear_registry():
|
||||
"""清空全局注册表 (用于测试)"""
|
||||
_registered_devices.clear()
|
||||
_registered_resources.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 枚举值归一化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]:
|
||||
"""将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。
|
||||
|
||||
适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。
|
||||
|
||||
处理以下格式:
|
||||
- "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm"
|
||||
- "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm"
|
||||
- "HANDLE" → DataSource["HANDLE"].value = "handle"
|
||||
- "NORTH" → Side["NORTH"].value = "NORTH"
|
||||
- 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
raw_str = str(raw)
|
||||
if "." in raw_str:
|
||||
raw_str = raw_str.rsplit(".", 1)[-1]
|
||||
try:
|
||||
return enum_cls[raw_str].value
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
return enum_cls(raw_str).value
|
||||
except ValueError:
|
||||
return raw_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# topic_config / not_action / always_free 装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def topic_config(
|
||||
period: Optional[float] = None,
|
||||
print_publish: Optional[bool] = None,
|
||||
qos: Optional[int] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Topic发布配置装饰器
|
||||
|
||||
用于装饰 get_{attr_name} 方法或 @property,控制对应属性的ROS topic发布行为。
|
||||
|
||||
Args:
|
||||
period: 发布周期(秒)。None 表示使用默认值 5.0
|
||||
print_publish: 是否打印发布日志。None 表示使用节点默认配置
|
||||
qos: QoS深度配置。None 表示使用默认值 10
|
||||
name: 自定义发布名称。None 表示使用方法名(去掉 get_ 前缀)
|
||||
|
||||
Note:
|
||||
与 @property 连用时,@topic_config 必须放在 @property 下面,
|
||||
这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._topic_period = period # type: ignore[attr-defined]
|
||||
wrapper._topic_print_publish = print_publish # type: ignore[attr-defined]
|
||||
wrapper._topic_qos = qos # type: ignore[attr-defined]
|
||||
wrapper._topic_name = name # type: ignore[attr-defined]
|
||||
wrapper._has_topic_config = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_topic_config(func) -> dict:
|
||||
"""获取函数上的 topic 配置 (period, print_publish, qos, name)"""
|
||||
if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False):
|
||||
return {
|
||||
"period": getattr(func, "_topic_period", None),
|
||||
"print_publish": getattr(func, "_topic_print_publish", None),
|
||||
"qos": getattr(func, "_topic_qos", None),
|
||||
"name": getattr(func, "_topic_name", None),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def always_free(func: F) -> F:
|
||||
"""
|
||||
标记动作为永久闲置(不受busy队列限制)的装饰器
|
||||
|
||||
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
|
||||
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_always_free(func) -> bool:
|
||||
"""检查函数是否被标记为永久闲置"""
|
||||
return getattr(func, "_is_always_free", False)
|
||||
|
||||
|
||||
def not_action(func: F) -> F:
|
||||
"""
|
||||
标记方法为非动作的装饰器
|
||||
|
||||
用于装饰 driver 类中的方法,使其在注册表扫描时不被识别为动作。
|
||||
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_not_action = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_not_action(func) -> bool:
|
||||
"""检查函数是否被标记为非动作"""
|
||||
return getattr(func, "_is_not_action", False)
|
||||
@@ -13,21 +13,18 @@ Qone_nmr:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -71,31 +68,6 @@ Qone_nmr:
|
||||
title: monitor_folder_for_new_content参数
|
||||
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: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-strings_to_txt:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -138,21 +110,18 @@ Qone_nmr:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -167,32 +136,31 @@ Qone_nmr:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -22,7 +22,8 @@ bioyond_cell:
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: auto_batch_outbound_from_xlsx参数
|
||||
@@ -490,7 +491,9 @@ bioyond_cell:
|
||||
goal:
|
||||
properties:
|
||||
material_names:
|
||||
type: string
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type_id:
|
||||
default: 3a190ca0-b2f6-9aeb-8067-547e72c11469
|
||||
type: string
|
||||
@@ -499,7 +502,8 @@ bioyond_cell:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: create_and_inbound_materials参数
|
||||
@@ -535,7 +539,8 @@ bioyond_cell:
|
||||
- type_id
|
||||
- warehouse_name
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: create_material参数
|
||||
@@ -556,11 +561,16 @@ bioyond_cell:
|
||||
goal:
|
||||
properties:
|
||||
mappings:
|
||||
additionalProperties:
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- mappings
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
items:
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- goal
|
||||
title: create_materials参数
|
||||
@@ -592,7 +602,8 @@ bioyond_cell:
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: create_orders参数
|
||||
@@ -624,7 +635,8 @@ bioyond_cell:
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: create_orders_v2参数
|
||||
@@ -665,7 +677,8 @@ bioyond_cell:
|
||||
- bottle_type
|
||||
- location_code
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: create_sample参数
|
||||
@@ -718,7 +731,8 @@ bioyond_cell:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: order_list_v2参数
|
||||
@@ -821,7 +835,8 @@ bioyond_cell:
|
||||
required:
|
||||
- material_obj
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: report_material_change参数
|
||||
@@ -875,7 +890,8 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_continue参数
|
||||
@@ -896,7 +912,8 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_reset参数
|
||||
@@ -917,7 +934,8 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
@@ -1362,7 +1380,8 @@ bioyond_cell:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start_and_auto_feeding参数
|
||||
@@ -1807,7 +1826,8 @@ bioyond_cell:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start_and_auto_feeding_v2参数
|
||||
@@ -1828,7 +1848,8 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_stop参数
|
||||
@@ -1850,12 +1871,15 @@ bioyond_cell:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: storage_batch_inbound参数
|
||||
@@ -1884,7 +1908,8 @@ bioyond_cell:
|
||||
- material_id
|
||||
- location_id
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: storage_inbound参数
|
||||
@@ -1905,7 +1930,8 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: transfer_1_to_2参数
|
||||
@@ -1946,7 +1972,8 @@ bioyond_cell:
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: transfer_3_to_2参数
|
||||
@@ -1983,7 +2010,8 @@ bioyond_cell:
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: transfer_3_to_2_to_1参数
|
||||
@@ -2007,10 +2035,11 @@ bioyond_cell:
|
||||
ip:
|
||||
type: string
|
||||
port:
|
||||
type: string
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: update_push_ip参数
|
||||
@@ -2039,7 +2068,8 @@ bioyond_cell:
|
||||
required:
|
||||
- order_code
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_order_finish参数
|
||||
@@ -2072,7 +2102,8 @@ bioyond_cell:
|
||||
required:
|
||||
- order_code
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_order_finish_polling参数
|
||||
@@ -2104,7 +2135,8 @@ bioyond_cell:
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_transfer_task参数
|
||||
@@ -2112,8 +2144,7 @@ bioyond_cell:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.bioyond_cell.bioyond_cell_workstation:BioyondCellWorkstation
|
||||
status_types:
|
||||
device_id: String
|
||||
material_info: dict
|
||||
device_id: ''
|
||||
type: python
|
||||
config_info: []
|
||||
description: ''
|
||||
@@ -2134,11 +2165,7 @@ bioyond_cell:
|
||||
properties:
|
||||
device_id:
|
||||
type: string
|
||||
material_info:
|
||||
type: object
|
||||
required:
|
||||
- device_id
|
||||
- material_info
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -24,7 +24,8 @@ bioyond_dispensing_station:
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: brief_step_parameters参数
|
||||
@@ -53,7 +54,8 @@ bioyond_dispensing_station:
|
||||
- report_request
|
||||
- used_materials
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: process_order_finish_report参数
|
||||
@@ -78,7 +80,8 @@ bioyond_dispensing_station:
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: project_order_report参数
|
||||
@@ -128,7 +131,8 @@ bioyond_dispensing_station:
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sample_locations参数
|
||||
@@ -144,12 +148,12 @@ bioyond_dispensing_station:
|
||||
temperature: temperature
|
||||
titration: titration
|
||||
goal_default:
|
||||
delay_time: '600'
|
||||
hold_m_name: ''
|
||||
delay_time: null
|
||||
hold_m_name: null
|
||||
liquid_material_name: NMP
|
||||
speed: '400'
|
||||
temperature: '40'
|
||||
titration: ''
|
||||
speed: null
|
||||
temperature: null
|
||||
titration: null
|
||||
handles:
|
||||
input:
|
||||
- data_key: titration
|
||||
@@ -165,20 +169,16 @@ bioyond_dispensing_station:
|
||||
handler_key: BATCH_CREATE_RESULT
|
||||
io_type: sink
|
||||
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
|
||||
result:
|
||||
return_info: return_info
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: BatchCreate9010VialFeedingTasks_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
default: '600'
|
||||
description: 延迟时间(秒),默认600
|
||||
type: string
|
||||
hold_m_name:
|
||||
@@ -189,11 +189,9 @@ bioyond_dispensing_station:
|
||||
description: 10%物料的液体物料名称,默认为"NMP"
|
||||
type: string
|
||||
speed:
|
||||
default: '400'
|
||||
description: 搅拌速度,默认400
|
||||
type: string
|
||||
temperature:
|
||||
default: '40'
|
||||
description: 温度(℃),默认40
|
||||
type: string
|
||||
titration:
|
||||
@@ -202,21 +200,14 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
required:
|
||||
- titration
|
||||
- hold_m_name
|
||||
title: BatchCreate9010VialFeedingTasks_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: BatchCreate9010VialFeedingTasks_Result
|
||||
type: object
|
||||
type: string
|
||||
required:
|
||||
- goal
|
||||
title: BatchCreate9010VialFeedingTasks
|
||||
title: batch_create_90_10_vial_feeding_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
batch_create_diamine_solution_tasks:
|
||||
@@ -228,11 +219,11 @@ bioyond_dispensing_station:
|
||||
speed: speed
|
||||
temperature: temperature
|
||||
goal_default:
|
||||
delay_time: '600'
|
||||
delay_time: null
|
||||
liquid_material_name: NMP
|
||||
solutions: ''
|
||||
speed: '400'
|
||||
temperature: '20'
|
||||
solutions: null
|
||||
speed: null
|
||||
temperature: null
|
||||
handles:
|
||||
input:
|
||||
- data_key: solutions
|
||||
@@ -248,20 +239,16 @@ bioyond_dispensing_station:
|
||||
handler_key: BATCH_CREATE_RESULT
|
||||
io_type: sink
|
||||
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
|
||||
result:
|
||||
return_info: return_info
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: BatchCreateDiamineSolutionTasks_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
default: '600'
|
||||
description: 溶液配置完成后的延迟时间(秒),用于充分混合和溶解,默认600秒
|
||||
type: string
|
||||
liquid_material_name:
|
||||
@@ -275,11 +262,9 @@ bioyond_dispensing_station:
|
||||
4.5, "solvent_volume": 18}]'
|
||||
type: string
|
||||
speed:
|
||||
default: '400'
|
||||
description: 搅拌速度(rpm),用于混合溶液,默认400转/分钟
|
||||
type: string
|
||||
temperature:
|
||||
default: '20'
|
||||
description: 配置温度(℃),溶液配置过程的目标温度,默认20℃(室温)
|
||||
type: string
|
||||
required:
|
||||
@@ -287,17 +272,11 @@ bioyond_dispensing_station:
|
||||
title: BatchCreateDiamineSolutionTasks_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: BatchCreateDiamineSolutionTasks_Result
|
||||
type: object
|
||||
type: string
|
||||
required:
|
||||
- goal
|
||||
title: BatchCreateDiamineSolutionTasks
|
||||
title: batch_create_diamine_solution_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
compute_experiment_design:
|
||||
@@ -309,7 +288,7 @@ bioyond_dispensing_station:
|
||||
wt_percent: wt_percent
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: ''
|
||||
ratio: null
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles:
|
||||
@@ -338,12 +317,8 @@ bioyond_dispensing_station:
|
||||
handler_key: feeding_order
|
||||
io_type: sink
|
||||
label: Feeding Order Data From Calculation Node
|
||||
result:
|
||||
feeding_order: feeding_order
|
||||
return_info: return_info
|
||||
solutions: solutions
|
||||
solvents: solvents
|
||||
titration: titration
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 计算实验设计,输出solutions/titration/solvents/feeding_order用于后续节点。
|
||||
properties:
|
||||
@@ -356,7 +331,7 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
ratio:
|
||||
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
|
||||
type: string
|
||||
type: object
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
description: 滴定比例(10%部分)
|
||||
@@ -371,14 +346,23 @@ bioyond_dispensing_station:
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
items: {}
|
||||
title: Feeding Order
|
||||
type: array
|
||||
return_info:
|
||||
title: Return Info
|
||||
type: string
|
||||
solutions:
|
||||
items: {}
|
||||
title: Solutions
|
||||
type: array
|
||||
solvents:
|
||||
additionalProperties: true
|
||||
title: Solvents
|
||||
type: object
|
||||
titration:
|
||||
additionalProperties: true
|
||||
title: Titration
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
@@ -386,11 +370,11 @@ bioyond_dispensing_station:
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesign_Result
|
||||
title: ComputeExperimentDesignReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: ComputeExperimentDesign
|
||||
title: compute_experiment_design参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
create_90_10_vial_feeding_task:
|
||||
@@ -444,17 +428,18 @@ bioyond_dispensing_station:
|
||||
speed: ''
|
||||
temperature: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: DispenStationVialFeed_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
@@ -502,38 +487,13 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- percent_90_1_assign_material_name
|
||||
- percent_90_1_target_weigh
|
||||
- percent_90_2_assign_material_name
|
||||
- percent_90_2_target_weigh
|
||||
- percent_90_3_assign_material_name
|
||||
- percent_90_3_target_weigh
|
||||
- percent_10_1_assign_material_name
|
||||
- percent_10_1_target_weigh
|
||||
- percent_10_1_volume
|
||||
- percent_10_1_liquid_material_name
|
||||
- percent_10_2_assign_material_name
|
||||
- percent_10_2_target_weigh
|
||||
- percent_10_2_volume
|
||||
- percent_10_2_liquid_material_name
|
||||
- percent_10_3_assign_material_name
|
||||
- percent_10_3_target_weigh
|
||||
- percent_10_3_volume
|
||||
- percent_10_3_liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationVialFeed_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationVialFeed_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -564,17 +524,18 @@ bioyond_dispensing_station:
|
||||
temperature: ''
|
||||
volume: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: DispenStationSolnPrep_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
@@ -594,24 +555,13 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
volume:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- material_name
|
||||
- target_weigh
|
||||
- volume
|
||||
- liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationSolnPrep_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationSolnPrep_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -624,8 +574,8 @@ bioyond_dispensing_station:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond配液站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
@@ -635,12 +585,6 @@ bioyond_dispensing_station:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 调度器启动结果,成功返回1,失败返回0
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
@@ -654,8 +598,8 @@ bioyond_dispensing_station:
|
||||
target_device_id: target_device_id
|
||||
transfer_groups: transfer_groups
|
||||
goal_default:
|
||||
target_device_id: ''
|
||||
transfer_groups: ''
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
target_device_id: unilabos_devices
|
||||
@@ -671,32 +615,13 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
transfer_groups:
|
||||
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
|
||||
items:
|
||||
properties:
|
||||
materials:
|
||||
description: 物料名称(手动输入,系统将通过RPC查询验证)
|
||||
type: string
|
||||
target_sites:
|
||||
description: 目标库位(手动输入,如"A01")
|
||||
type: string
|
||||
target_stack:
|
||||
description: 目标堆栈名称(从列表选择)
|
||||
enum:
|
||||
- 堆栈1左
|
||||
- 堆栈1右
|
||||
- 站内试剂存放堆栈
|
||||
type: string
|
||||
required:
|
||||
- materials
|
||||
- target_stack
|
||||
- target_sites
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
@@ -709,9 +634,9 @@ bioyond_dispensing_station:
|
||||
check_interval: check_interval
|
||||
timeout: timeout
|
||||
goal_default:
|
||||
batch_create_result: ''
|
||||
check_interval: '10'
|
||||
timeout: '7200'
|
||||
batch_create_result: null
|
||||
check_interval: 10
|
||||
timeout: 7200
|
||||
handles:
|
||||
input:
|
||||
- data_key: batch_create_result
|
||||
@@ -727,47 +652,35 @@ bioyond_dispensing_station:
|
||||
handler_key: batch_reports_result
|
||||
io_type: sink
|
||||
label: Batch Order Completion Reports
|
||||
result:
|
||||
return_info: return_info
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 同时等待多个任务完成并获取所有实验报告。从上游batch_create任务接收包含order_codes和order_ids的结果对象,并行监控所有任务状态并返回每个任务的报告。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: WaitForMultipleOrdersAndGetReports_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
batch_create_result:
|
||||
description: 批量创建任务的返回结果对象,包含order_codes和order_ids数组。从上游batch_create节点通过handle传递
|
||||
type: string
|
||||
check_interval:
|
||||
default: '10'
|
||||
default: 10
|
||||
description: 检查任务状态的时间间隔(秒),默认每10秒检查一次所有待完成任务
|
||||
type: string
|
||||
type: integer
|
||||
timeout:
|
||||
default: '7200'
|
||||
default: 7200
|
||||
description: 等待超时时间(秒),默认7200秒(2小时)。超过此时间未完成的任务将标记为timeout
|
||||
type: string
|
||||
required:
|
||||
- batch_create_result
|
||||
type: integer
|
||||
required: []
|
||||
title: WaitForMultipleOrdersAndGetReports_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 'JSON格式的批量任务完成信息,包含: total(总数), completed(成功数), timeout(超时数),
|
||||
error(错误数), elapsed_time(总耗时), reports(报告数组,每个元素包含order_code,
|
||||
order_id, status, completion_status, report, elapsed_time)'
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: WaitForMultipleOrdersAndGetReports_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: WaitForMultipleOrdersAndGetReports
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station.dispensing_station:BioyondDispensingStation
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
camera:
|
||||
category:
|
||||
- camera
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-destroy_node:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 用于安全地关闭摄像头设备,释放摄像头资源,停止视频采集和发布服务。调用此函数将清理OpenCV摄像头连接并销毁ROS2节点。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: destroy_node参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-timer_callback:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 定时器回调函数的参数schema。此函数负责定期采集摄像头视频帧,将OpenCV格式的图像转换为ROS Image消息格式,并发布到指定的视频话题。默认以10Hz频率执行,确保视频流的连续性和实时性。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: timer_callback参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.ros.nodes.presets.camera:VideoPublisher
|
||||
status_types: {}
|
||||
type: ros2
|
||||
config_info: []
|
||||
description: VideoPublisher摄像头设备节点,用于实时视频采集和流媒体发布。该设备通过OpenCV连接本地摄像头(如USB摄像头、内置摄像头等),定时采集视频帧并将其转换为ROS2的sensor_msgs/Image消息格式发布到视频话题。主要用于实验室自动化系统中的视觉监控、图像分析、实时观察等应用场景。支持可配置的摄像头索引、发布频率等参数。
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
camera_index:
|
||||
default: 0
|
||||
type: string
|
||||
device_id:
|
||||
default: video_publisher
|
||||
type: string
|
||||
device_uuid:
|
||||
default: ''
|
||||
type: string
|
||||
period:
|
||||
default: 0.1
|
||||
type: number
|
||||
registry_name:
|
||||
default: ''
|
||||
type: string
|
||||
resource_tracker:
|
||||
type: object
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -18,7 +18,7 @@ cameracontroller_device:
|
||||
goal:
|
||||
properties:
|
||||
config:
|
||||
type: string
|
||||
type: object
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
@@ -42,7 +42,8 @@ cameracontroller_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: stop参数
|
||||
@@ -50,7 +51,7 @@ cameracontroller_device:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.cameraSII.cameraUSB:CameraController
|
||||
status_types:
|
||||
status: dict
|
||||
status: Dict[str, Any]
|
||||
type: python
|
||||
config_info: []
|
||||
description: Uni-Lab-OS 摄像头驱动(Linux USB 摄像头版,无 PTZ)
|
||||
@@ -103,5 +104,4 @@ cameracontroller_device:
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -141,30 +141,26 @@ hplc.agilent:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -175,7 +171,6 @@ hplc.agilent:
|
||||
module: unilabos.devices.hplc.AgilentHPLC:HPLCDriver
|
||||
status_types:
|
||||
could_run: bool
|
||||
data_file: String
|
||||
device_status: str
|
||||
driver_init_ok: bool
|
||||
finish_status: str
|
||||
@@ -199,10 +194,6 @@ hplc.agilent:
|
||||
properties:
|
||||
could_run:
|
||||
type: boolean
|
||||
data_file:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
device_status:
|
||||
type: string
|
||||
driver_init_ok:
|
||||
@@ -216,14 +207,13 @@ hplc.agilent:
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- status_text
|
||||
- device_status
|
||||
- could_run
|
||||
- device_status
|
||||
- driver_init_ok
|
||||
- is_running
|
||||
- success
|
||||
- finish_status
|
||||
- data_file
|
||||
- is_running
|
||||
- status_text
|
||||
- success
|
||||
type: object
|
||||
version: 1.0.0
|
||||
hplc.agilent-zhida:
|
||||
@@ -236,26 +226,25 @@ hplc.agilent-zhida:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -315,21 +304,18 @@ hplc.agilent-zhida:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -341,35 +327,35 @@ hplc.agilent-zhida:
|
||||
feedback: {}
|
||||
goal:
|
||||
string: string
|
||||
text: text
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -407,7 +393,7 @@ hplc.agilent-zhida:
|
||||
status:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- methods
|
||||
- status
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -120,42 +120,41 @@ raman.home_made:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
raman_cmd:
|
||||
feedback: {}
|
||||
feedback:
|
||||
status: status
|
||||
goal:
|
||||
command: command
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -19,7 +19,8 @@ separator.chinwe:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: connect参数
|
||||
@@ -65,135 +66,145 @@ separator.chinwe:
|
||||
required:
|
||||
- command_dict
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: execute_command_from_outer参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_rotate_quarter:
|
||||
feedback: {}
|
||||
goal:
|
||||
direction: 顺时针
|
||||
motor_id: 4
|
||||
speed: 60
|
||||
goal_default:
|
||||
direction: 顺时针
|
||||
motor_id: null
|
||||
speed: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 电机旋转 1/4 圈
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
direction:
|
||||
default: 顺时针
|
||||
description: 旋转方向
|
||||
enum:
|
||||
- 顺时针
|
||||
- 逆时针
|
||||
type: string
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机 (4:搅拌, 5:旋钮)
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
type: string
|
||||
type: integer
|
||||
speed:
|
||||
default: 60
|
||||
description: 速度 (RPM)
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
- speed
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_rotate_quarter参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_run_continuous:
|
||||
feedback: {}
|
||||
goal:
|
||||
direction: 顺时针
|
||||
motor_id: 4
|
||||
speed: 60
|
||||
goal_default:
|
||||
direction: 顺时针
|
||||
motor_id: null
|
||||
speed: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 电机一直旋转 (速度模式)
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
direction:
|
||||
default: 顺时针
|
||||
description: 旋转方向
|
||||
enum:
|
||||
- 顺时针
|
||||
- 逆时针
|
||||
type: string
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机 (4:搅拌, 5:旋钮)
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
type: string
|
||||
type: integer
|
||||
speed:
|
||||
default: 60
|
||||
description: 速度 (RPM)
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
- speed
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_run_continuous参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_stop:
|
||||
feedback: {}
|
||||
goal:
|
||||
motor_id: 4
|
||||
goal_default:
|
||||
motor_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 停止指定步进电机
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
title: '注: 4=搅拌, 5=旋钮'
|
||||
type: string
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_aspirate:
|
||||
feedback: {}
|
||||
goal:
|
||||
pump_id: 1
|
||||
valve_port: 1
|
||||
volume: 1000
|
||||
goal_default:
|
||||
pump_id: null
|
||||
valve_port: null
|
||||
volume: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 注射泵吸液
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
type: integer
|
||||
valve_port:
|
||||
default: '1'
|
||||
description: 阀门端口
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
type: integer
|
||||
volume:
|
||||
default: 1000
|
||||
description: 吸液步数
|
||||
type: integer
|
||||
required:
|
||||
@@ -201,41 +212,38 @@ separator.chinwe:
|
||||
- volume
|
||||
- valve_port
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_aspirate参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_dispense:
|
||||
feedback: {}
|
||||
goal:
|
||||
pump_id: 1
|
||||
valve_port: 1
|
||||
volume: 1000
|
||||
goal_default:
|
||||
pump_id: null
|
||||
valve_port: null
|
||||
volume: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 注射泵排液
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
type: integer
|
||||
valve_port:
|
||||
default: '1'
|
||||
description: 阀门端口
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
type: integer
|
||||
volume:
|
||||
default: 1000
|
||||
description: 排液步数
|
||||
type: integer
|
||||
required:
|
||||
@@ -243,162 +251,152 @@ separator.chinwe:
|
||||
- volume
|
||||
- valve_port
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_dispense参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_initialize:
|
||||
feedback: {}
|
||||
goal:
|
||||
drain_port: 0
|
||||
output_port: 0
|
||||
pump_id: 1
|
||||
speed: 10
|
||||
goal_default:
|
||||
drain_port: 0
|
||||
output_port: 0
|
||||
pump_id: null
|
||||
speed: 10
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 初始化指定注射泵
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
drain_port:
|
||||
default: 0
|
||||
description: 排液口索引
|
||||
type: integer
|
||||
type: string
|
||||
output_port:
|
||||
default: 0
|
||||
description: 输出口索引
|
||||
type: integer
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
title: '注: 1号泵, 2号泵, 3号泵'
|
||||
type: string
|
||||
pump_id:
|
||||
description: 选择泵
|
||||
title: '注: 1号泵, 2号泵, 3号泵'
|
||||
type: integer
|
||||
speed:
|
||||
default: 10
|
||||
description: 运动速度
|
||||
type: integer
|
||||
type: string
|
||||
required:
|
||||
- pump_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_initialize参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_valve:
|
||||
feedback: {}
|
||||
goal:
|
||||
port: 1
|
||||
pump_id: 1
|
||||
goal_default:
|
||||
port: null
|
||||
pump_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 切换指定泵的阀门端口
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
port:
|
||||
default: '1'
|
||||
description: 阀门端口号 (1-8)
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
type: integer
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
type: integer
|
||||
required:
|
||||
- pump_id
|
||||
- port
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
separation_step:
|
||||
goal:
|
||||
max_cycles: 0
|
||||
motor_id: 5
|
||||
pulses: 700
|
||||
speed: 60
|
||||
timeout: 300
|
||||
handles: {}
|
||||
schema:
|
||||
description: 分液步骤 - 液位传感器与电机联动 (有液→顺时针, 无液→逆时针)
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
max_cycles:
|
||||
default: 0
|
||||
description: 最大循环次数 (0=无限制)
|
||||
type: integer
|
||||
motor_id:
|
||||
default: '5'
|
||||
description: 选择电机
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
title: '注: 4=搅拌, 5=旋钮'
|
||||
type: string
|
||||
pulses:
|
||||
default: 700
|
||||
description: 每次旋转脉冲数 (约1/4圈)
|
||||
type: integer
|
||||
speed:
|
||||
default: 60
|
||||
description: 电机转速 (RPM)
|
||||
type: integer
|
||||
timeout:
|
||||
default: 300
|
||||
description: 超时时间 (秒)
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_valve参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_sensor_level:
|
||||
feedback: {}
|
||||
goal:
|
||||
target_state: 有液
|
||||
timeout: 30
|
||||
goal_default:
|
||||
target_state: 有液
|
||||
timeout: 30
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 等待传感器液位条件
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_state:
|
||||
default: 有液
|
||||
description: 目标液位状态
|
||||
enum:
|
||||
- 有液
|
||||
- 无液
|
||||
type: string
|
||||
timeout:
|
||||
default: 30
|
||||
description: 超时时间 (秒)
|
||||
type: integer
|
||||
required:
|
||||
- target_state
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: wait_sensor_level参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_time:
|
||||
feedback: {}
|
||||
goal:
|
||||
duration: 10
|
||||
goal_default:
|
||||
duration: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 等待指定时间
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
duration:
|
||||
default: 10
|
||||
description: 等待时间 (秒)
|
||||
type: integer
|
||||
required:
|
||||
- duration
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: wait_time参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.separator.chinwe:ChinweDevice
|
||||
status_types:
|
||||
@@ -447,8 +445,8 @@ separator.chinwe:
|
||||
sensor_rssi:
|
||||
type: integer
|
||||
required:
|
||||
- is_connected
|
||||
- sensor_level
|
||||
- sensor_rssi
|
||||
- is_connected
|
||||
type: object
|
||||
version: 2.1.0
|
||||
|
||||
@@ -64,7 +64,8 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: fun_wuliao_test参数
|
||||
@@ -109,7 +110,8 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd参数
|
||||
@@ -220,7 +222,8 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd_simp参数
|
||||
@@ -309,7 +312,8 @@ coincellassemblyworkstation_device:
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init_auto_start_combined参数
|
||||
@@ -351,7 +355,8 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_stop参数
|
||||
@@ -376,7 +381,8 @@ coincellassemblyworkstation_device:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_get_msg_cmd参数
|
||||
@@ -430,7 +436,8 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_finished_cmd参数
|
||||
@@ -467,7 +474,8 @@ coincellassemblyworkstation_device:
|
||||
- assembly_type
|
||||
- assembly_pressure
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_msg_cmd参数
|
||||
@@ -611,7 +619,8 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_sendbottle_allpack_multi参数
|
||||
@@ -663,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: {}
|
||||
@@ -735,7 +719,8 @@ coincellassemblyworkstation_device:
|
||||
required:
|
||||
- fujipian_panshu
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: qiming_coin_cell_code参数
|
||||
@@ -826,25 +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_assembly_pressure
|
||||
- data_assembly_time
|
||||
- data_open_circuit_voltage
|
||||
- data_axis_x_pos
|
||||
- data_axis_y_pos
|
||||
- data_axis_z_pos
|
||||
- data_pole_weight
|
||||
- data_assembly_pressure
|
||||
- data_electrolyte_volume
|
||||
- data_coin_num
|
||||
- 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_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
|
||||
|
||||
@@ -50,26 +50,25 @@ gas_source.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -82,26 +81,25 @@ gas_source.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -116,32 +114,31 @@ gas_source.mock:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -232,26 +229,25 @@ vacuum_pump.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -264,26 +260,25 @@ vacuum_pump.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -298,32 +293,31 @@ vacuum_pump.mock:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
result: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
additionalProperties: true
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -5,7 +5,7 @@ hotel.thermo_orbitor_rs2_hotel:
|
||||
action_value_mappings: {}
|
||||
module: unilabos.devices.resource_container.container:HotelContainer
|
||||
status_types:
|
||||
rotation: String
|
||||
rotation: ''
|
||||
type: python
|
||||
config_info: []
|
||||
description: Thermo Orbitor RS2 Hotel容器设备,用于实验室样品的存储和管理。该设备通过HotelContainer类实现容器的旋转控制和状态监控,主要用于存储实验样品、试剂瓶或其他实验器具,支持旋转功能以便于样品的自动化存取。适用于需要有序存储和快速访问大量样品的实验室自动化场景。
|
||||
|
||||
@@ -22,7 +22,8 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- degrees
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: integer
|
||||
required:
|
||||
- goal
|
||||
title: degrees_to_steps参数
|
||||
@@ -47,7 +48,8 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: emergency_stop参数
|
||||
@@ -72,7 +74,10 @@ xyz_stepper_controller:
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: enable_all_axes参数
|
||||
@@ -101,7 +106,8 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: enable_motor参数
|
||||
@@ -122,7 +128,10 @@ xyz_stepper_controller:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: home_all_axes参数
|
||||
@@ -147,7 +156,8 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: home_axis参数
|
||||
@@ -188,7 +198,8 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- position
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position参数
|
||||
@@ -229,7 +240,8 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- degrees
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_degrees参数
|
||||
@@ -270,7 +282,8 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- revolutions
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_revolutions参数
|
||||
@@ -301,14 +314,17 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x:
|
||||
type: string
|
||||
type: integer
|
||||
y:
|
||||
type: string
|
||||
type: integer
|
||||
z:
|
||||
type: string
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz参数
|
||||
@@ -339,14 +355,17 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_deg:
|
||||
type: string
|
||||
type: number
|
||||
y_deg:
|
||||
type: string
|
||||
type: number
|
||||
z_deg:
|
||||
type: string
|
||||
type: number
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_degrees参数
|
||||
@@ -377,14 +396,17 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_rev:
|
||||
type: string
|
||||
type: number
|
||||
y_rev:
|
||||
type: string
|
||||
type: number
|
||||
z_rev:
|
||||
type: string
|
||||
type: number
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_revolutions参数
|
||||
@@ -409,7 +431,8 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- revolutions
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: integer
|
||||
required:
|
||||
- goal
|
||||
title: revolutions_to_steps参数
|
||||
@@ -442,7 +465,8 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- speed
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: set_speed_mode参数
|
||||
@@ -467,7 +491,8 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: number
|
||||
required:
|
||||
- goal
|
||||
title: steps_to_degrees参数
|
||||
@@ -492,7 +517,8 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: number
|
||||
required:
|
||||
- goal
|
||||
title: steps_to_revolutions参数
|
||||
@@ -513,7 +539,10 @@ xyz_stepper_controller:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: stop_all_axes参数
|
||||
@@ -542,7 +571,8 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_completion参数
|
||||
@@ -550,8 +580,7 @@ xyz_stepper_controller:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:XYZStepperController
|
||||
status_types:
|
||||
all_positions: dict
|
||||
motor_status: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:MotorPosition
|
||||
all_positions: Dict[MotorAxis, MotorPosition]
|
||||
type: python
|
||||
config_info: []
|
||||
description: 新XYZ控制器
|
||||
@@ -574,12 +603,10 @@ xyz_stepper_controller:
|
||||
data:
|
||||
properties:
|
||||
all_positions:
|
||||
type: object
|
||||
motor_status:
|
||||
additionalProperties:
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- motor_status
|
||||
- all_positions
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user