Compare commits

..

51 Commits

Author SHA1 Message Date
ZiWei
e212dc7781 node_to_plr_dict: WareHouse 子节点不写入 PLR children 列表
WareHouse 通过 sites 字符串追踪占位,不依赖 PLR children tree。
当同一载架(如 BIOYOND_PolymerStation_1BottleCarrier)出现在多个
WareHouse 的 children 下时,PLR _check_naming_conflicts 会因
同名子资源(flask_1)重复而报 ValueError。

将 WareHouse 的 children 排除在 PLR dict 外,PLR 树只保留
WareHouse 本身(位置/尺寸),不包含其持有的载架。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:42:27 +08:00
ZiWei
96c4be17dc merge_remote_resources: 以远端为准,移除本地已不在远端的物料
远端(bioyond)不存在的物料不应保留在本地资源树中,
否则这些过期物料会在 PLR 反序列化时产生命名冲突。
同步时对两级子节点均执行移除:
- 三级物料(设备→仓库→物料)
- 三级子物料(设备→物料→子物料)

同时修复 else 分支缺少 remote_child_name 存在性检查的潜在 KeyError。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:42:27 +08:00
ZiWei
44afc7733b 修复 WareHouse 反序列化时子资源命名冲突,并更新配液站测试 UUID
- itemized_carrier: assign_child_resource idx=None 时直接 return,
  不调用 super(),避免 bottle_carrier 子树进入 PLR 命名冲突检查
- dispensing_station_bioyond.json: 替换 placeholder UUID 为真实配置值

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:42:27 +08:00
ZiWei
a34ffcaeb9 优化资源搜索逻辑,避免同一资源对象重复注册导致的错误 2026-04-28 11:39:08 +08:00
ZiWei
70c6685283 优化资源分配逻辑,避免在反序列化时因名称冲突导致的错误处理 2026-04-28 11:27:58 +08:00
ZiWei
7027bd5ed1 防止 Deck 子类在 __init__ 中调用 setup() 预分配子资源,避免与 PLR deserialize 产生命名冲突 2026-04-28 10:12:21 +08:00
ZiWei
57f5c8752d 添加 BIOYOND_PolymerStation_TipBox 到瓶子和小提示盒分类 2026-04-27 17:09:20 +08:00
ZiWei
827169827a Remove outdated templates and validation guides for device and experiment graph skills.
- Deleted `templates.md` for workstation code templates.
- Removed `SKILL.md` and `reference.md` for editing experiment graphs.
- Deleted `SKILL.md` for validating device implementations.
2026-04-27 11:20:17 +08:00
ZiWei
c4a2f68649 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-04-27 11:13:13 +08:00
Xuwznln
195fad9398 upgrade to 0.11.1 2026-04-22 19:54:16 +08:00
Xuwznln
898ed5d34b use gitee to install pylabrobot
fix virtual import
2026-04-22 19:51:10 +08:00
Xuwznln
60cbedc4b2 v0.11.0
(cherry picked from commit 67a74172dc)
2026-04-22 19:50:42 +08:00
Xuwznln
2d6a9f7db9 fix possible conversion error 2026-04-22 00:09:06 +08:00
Xuwznln
5dca3d8c3d update workbench example 2026-04-15 16:33:43 +08:00
Xuwznln
37cbed722a update aksk desc 2026-04-13 23:17:43 +08:00
Xuwznln
132cffbe7c print res query logs 2026-04-13 20:24:48 +08:00
Xuwznln
36e5ff804c Fix skills exec error with action type 2026-04-13 20:16:00 +08:00
Xuwznln
eaf8ad5609 Fix skills exec error with action type 2026-04-13 17:02:38 +08:00
Xuwznln
16122ad2fa Update Skills 2026-04-13 15:57:50 +08:00
Xuwznln
d3fef85dd8 Update Skills addr 2026-04-13 11:15:35 +08:00
Xuwznln
f77ac2a5e8 Change uni-lab. to leap-lab.
Support unit in pylabrobot
2026-04-12 15:32:27 +08:00
Xuwznln
93ac55a65b Support async func. 2026-04-11 18:13:08 +08:00
Xuwznln
af35debe38 change to leap-lab backend. Support feedback interval. Reduce cocurrent lags. 2026-04-11 06:22:53 +08:00
Xuwznln
58997f0654 fix create_resource_with_slot 2026-04-09 17:34:25 +08:00
Xuwznln
fbfc3e30fb update unilabos_formulation & batch-submit-exp 2026-04-09 16:40:31 +08:00
Xuwznln
1d1c1367df scale multi exec thread up to 48 2026-04-09 14:15:38 +08:00
Xuwznln
c91b600e90 update handle creation api 2026-04-02 22:53:31 +08:00
Xuwznln
49b3c850f9 fit cocurrent gap 2026-04-02 16:01:23 +08:00
Xuwznln
25c94af755 add running status debounce 2026-04-01 16:01:22 +08:00
Xuwznln
861a012747 allow non @topic_config support 2026-03-31 13:15:06 +08:00
ZiWei
d68fc5e380 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction
# Conflicts:
#	.cursor/skills/add-workstation/SKILL.md
#	.cursor/skills/add-workstation/reference.md
2026-03-27 11:49:30 +08:00
Xuwznln
ee63e95f50 update skill 2026-03-25 23:20:13 +08:00
ZiWei
f0ea32f163 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction
# Conflicts:
#	.cursor/skills/add-device/SKILL.md
#	.cursor/skills/add-resource/SKILL.md
#	AGENTS.md
#	CLAUDE.md
2026-03-24 17:06:54 +08:00
ZiWei
3c8020813b feat: 添加设备验证指南,确保设备实现符合接口契约和编码标准 2026-03-17 09:54:12 +08:00
ZiWei
97996d316f Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-03-13 15:07:30 +08:00
ZiWei
9815961a1f feat: Add new developer guides for old devices and PLC framework integration 2026-03-11 14:10:11 +08:00
ZiWei
fe501c965f feat: Update workstation reference and templates with new PLC integration details and enhanced workflow mappings 2026-03-11 14:09:46 +08:00
ZiWei
92bfb069d5 feat: Implement Laiyu liquid handling station with enhanced device control, testing, and documentation. 2026-03-09 18:44:20 +08:00
ZiWei
b61c818f7f Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-03-09 09:39:17 +08:00
ZiWei
47a29a0c2f add:skill&agent 2026-03-06 16:54:31 +08:00
ZiWei
9c6f7c7505 Merge branch 'dev' into feature/organic-extraction 2026-03-02 15:32:36 +08:00
ZiWei
e4e4bfbe20 Merge branch 'dev' into feature/organic-extraction 2026-02-04 15:47:47 +08:00
ZiWei
64c748d921 Merge branch 'vibe/dev' into feature/organic-extraction 2026-02-03 10:39:44 +08:00
ZiWei
15ff0e9d30 feat: add Bioyond deck imports to resource registration 2026-02-03 10:28:51 +08:00
ZiWei
f8a52860ad Add BIOYOND deck imports and update JSON configurations with new UUIDs for various components 2026-02-03 10:25:47 +08:00
Xuwznln
e30c01d54e Dev backward (#228)
* Workbench example, adjust log level, and ci check (#220)

* TestLatency Return Value Example & gitignore update

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

* Add CI Check

* CI Check Fix 1

* CI Check Fix 2

* CI Check Fix 3

* CI Check Fix 4

* CI Check Fix 5

* Upgrade to py 3.11.14; ros 0.7; unilabos 0.10.16

* Update to ROS2 Humble 0.7

* Fix Build 1

* Fix Build 2

* Fix Build 3

* Fix Build 4

* Fix Build 5

* Fix Build 6

* Fix Build 7

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix Build 8

* Fix Build 9

* Fix Build 10

* Fix Build 11

* Fix Build 12

* Fix Build 13

* v0.10.17

(cherry picked from commit 176de521b4)

* CI Check use production mode

* Fix OT2 & ReAdd Virtual Devices

* add msg goal

* transfer liquid handles

* gather query

* add unilabos_class

* Support root node change pos

* save class name when deserialize & protocol execute test

* fix upload workflow json

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

* speed up registry load

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hanhua@dp.tech <2509856570@qq.com>
2026-02-02 23:57:13 +08:00
ZiWei
37ec49f318 Refactor Bioyond resource handling: update warehouse mapping retrieval, add TipBox support, and improve liquid tracking logic. Migrate TipBox creation to bottle_carriers.py for better structure. 2026-01-29 16:31:14 +08:00
ZiWei
6bf57f18c1 Collaboration With Cursor 2026-01-29 11:29:38 +08:00
ZiWei
c4a3be1498 feat: enhance separation_step logic with polling thread management and error handling 2026-01-27 12:37:09 +08:00
ZiWei
e11070315d feat: add separation_step with sensor-motor linkage 2026-01-26 23:34:47 +08:00
ZiWei
50ebcad9d7 feat: add ZDT_X42 motor and XKC sensor drivers 2026-01-22 15:07:32 +08:00
139 changed files with 14264 additions and 6122 deletions

View File

@@ -3,7 +3,7 @@
package:
name: unilabos
version: 0.10.19
version: 0.11.1
source:
path: ../../unilabos
@@ -54,7 +54,7 @@ requirements:
- pymodbus
- matplotlib
- pylibftdi
- uni-lab::unilabos-env ==0.10.19
- uni-lab::unilabos-env ==0.11.1
about:
repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

@@ -2,7 +2,7 @@
package:
name: unilabos-env
version: 0.10.19
version: 0.11.1
build:
noarch: generic

View File

@@ -3,7 +3,7 @@
package:
name: unilabos-full
version: 0.10.19
version: 0.11.1
build:
noarch: generic
@@ -11,7 +11,7 @@ build:
requirements:
run:
# Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.10.19
- uni-lab::unilabos ==0.11.1
# Documentation tools
- sphinx
- sphinx_rtd_theme

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{
### 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` |
| `--addr` | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
@@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
@@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
```
返回成功时包含试剂 UUID
```json
{"code": 0, "data": {"uuid": "xxx", ...}}
```
@@ -98,28 +100,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
## 试剂字段说明
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `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"` |
| 字段 | 类型 | 必填 | 说明 | 示例 |
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
| `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"` | 克 |
| `"L"` | 升 |
| `"g"` | 克 |
| `"kg"` | 千克 |
| `"瓶"` | 瓶 |
| `"瓶"` | 瓶 |
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
@@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
```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"}
{
"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"
}
]
```
@@ -160,9 +176,20 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
```
### 日期格式规则(重要)
所有日期字段(`production_date``expiry_date`**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`API 会拒绝。
### 执行与汇报
每次 API 调用后:
1. 检查返回 `code`0 = 成功)
2. 记录成功/失败数量
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
@@ -172,28 +199,29 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
## 常见试剂速查表
| 名称 | 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 |
| 名称 | CAS | 分子式 | SMILES |
| --------------------- | --------- | ---------- | ------------------------------------ |
| 水 | 7732-18-3 | H2O | O |
| 乙醇 | 64-17-5 | C2H6O | CCO |
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
| 甲醇 | 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 应根据化学知识推断或提示用户补充。

View File

@@ -1,11 +1,13 @@
---
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/实验轮次.
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
---
# 批量提交实验指南
# Uni-Lab 批量提交实验指南
通过云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
通过 Uni-Lab 云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
@@ -18,25 +20,28 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成
# 方式一Python 一行生成注意scheme 是 "Lab" 不是 "Basic"
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>
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme绝对不能用 "Basic" 替代
```
### 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` |
| `--addr` | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
# ⚠️ Auth scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
@@ -44,22 +49,23 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
按优先级搜索
**必须先用 Glob 工具搜索文件**,不要直接猜测路径
```
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
<workspace 根目录>/req_device_registry_upload.json
Glob: **/req_device_registry_upload.json
```
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
常见位置(仅供参考,以 Glob 实际结果为准):
- `<workspace>/unilabos_data/req_device_registry_upload.json`
- `<workspace>/req_device_registry_upload.json`
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
### 4. workflow_uuid目标工作流
用户需要提供要提交的 workflow UUID。如果用户不确定通过 API #2 列出可用 workflow 供选择。
用户需要提供要提交的 workflow UUID。如果用户不确定通过 API #3 列出可用 workflow 供选择。
**四项全部就绪后才可开始。**
@@ -68,8 +74,9 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `project_uuid` — 项目 UUID通过 API #2 列出项目列表,**让用户选择**
- `workflow_uuid` — 工作流 UUID用户提供或从列表选择
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名从 API #3 获取)
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名从 API #4 获取)
## 请求约定
@@ -92,12 +99,46 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
### 2. 列出可用 workflow
### 2. 列出实验室项目(让用户选择项目)
```bash
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
```
返回:
```json
{
"code": 0,
"data": {
"items": [
{
"uuid": "1b3f249a-...",
"name": "bt",
"description": null,
"status": "active",
"created_at": "2026-04-09T14:31:28+08:00"
},
{
"uuid": "b6366243-...",
"name": "default",
"description": "默认项目",
"status": "active",
"created_at": "2026-03-26T11:13:36+08:00"
}
]
}
}
```
展示 `data.items[]` 中每个项目的 `name``uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
### 3. 列出可用 workflow
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
@@ -105,13 +146,14 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid``name`
### 3. 获取 workflow 模板详情
### 4. 获取 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`
@@ -119,7 +161,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
### 4. 提交实验(创建 notebook
### 5. 提交实验(创建 notebook
```bash
curl -s -X POST "$BASE/api/v1/lab/notebook" \
@@ -131,34 +173,45 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
```json
{
"lab_uuid": "<lab_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
]
}
]
}
```
> **注意**`sample_uuids` 必须是 **UUID 数组**`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
### 6. 查询 notebook 状态
提交成功后,使用返回的 notebook UUID 查询执行状态:
```bash
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
```
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
---
## Notebook 请求体详解
@@ -172,25 +225,25 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
### 每轮的字段
| 字段 | 类型 | 说明 |
|------|------|------|
| 字段 | 类型 | 说明 |
| -------------- | ------------- | ----------------------------------------- |
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
### datas 中每个节点
| 字段 | 类型 | 说明 |
|------|------|------|
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #3 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
| 字段 | 类型 | 说明 |
| --------------- | ------ | -------------------------------------------- |
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #4 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
### sample_params 中每条
| 字段 | 类型 | 说明 |
|------|------|------|
| `container_uuid` | string | 容器 UUID |
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
| 字段 | 类型 | 说明 |
| ---------------- | ------ | ---------------------------------------------------- |
| `container_uuid` | string | 容器 UUID |
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
---
@@ -211,6 +264,7 @@ python scripts/gen_notebook_params.py \
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
脚本会:
1. 调用 workflow detail API 获取所有 action 节点
2. 读取本地注册表,为每个节点查找对应的 action schema
3. 生成 `notebook_template.json`,包含:
@@ -222,7 +276,7 @@ python scripts/gen_notebook_params.py \
如果脚本不可用或注册表不存在:
1. 调用 API #3 获取 workflow 详情
1. 调用 API #4 获取 workflow 详情
2. 找到每个 action 节点的 `node_uuid`
3. 在本地注册表中查找对应设备的 `action_value_mappings`
```
@@ -248,8 +302,11 @@ python scripts/gen_notebook_params.py \
"properties": {
"goal": {
"properties": {
"asp_vols": {"type": "array", "items": {"type": "number"}},
"sources": {"type": "array"}
"asp_vols": {
"type": "array",
"items": { "type": "number" }
},
"sources": { "type": "array" }
},
"required": ["asp_vols", "sources"]
}
@@ -275,13 +332,15 @@ 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: 检查返回结果,确认提交成功
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
- [ ] Step 5: 确认 workflow_uuid用户提供或从 GET #3 列表选择)
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
- [ ] Step 9: 引导用户填写每轮的参数sample_uuids、param、sample_params
- [ ] Step 10: 构建完整请求体(含 project_uuid→ POST /lab/notebook 提交
- [ ] Step 11: 检查返回结果,记录 notebook UUID
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
```
---

View File

@@ -7,7 +7,7 @@
选项:
--auth <token> Lab tokenbase64(ak:sk) 的结果,不含 "Lab " 前缀)
--base <url> API 基础 URL如 https://uni-lab.test.bohrium.com
--base <url> API 基础 URL如 https://leap-lab.test.bohrium.com
--workflow-uuid <uuid> 目标 workflow 的 UUID
--registry <path> 本地注册表文件路径(默认自动搜索)
--rounds <n> 实验轮次数(默认 1
@@ -17,7 +17,7 @@
示例:
python gen_notebook_params.py \\
--auth YTFmZDlkNGUtxxxx \\
--base https://uni-lab.test.bohrium.com \\
--base https://leap-lab.test.bohrium.com \\
--workflow-uuid abc-123-def \\
--rounds 2
"""
@@ -265,6 +265,7 @@ def generate_template(nodes, registry_index, rounds):
return {
"lab_uuid": "$TODO_LAB_UUID",
"project_uuid": "$TODO_PROJECT_UUID",
"workflow_uuid": "$TODO_WORKFLOW_UUID",
"name": "$TODO_EXPERIMENT_NAME",
"node_params": node_params,

View File

@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
决定 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 |
| `--addr` | BASE URL |
| -------------- | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
| 其他自定义 URL | 直接使用该 URL |
#### 必备项 ③req_device_registry_upload.json设备注册表
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
**推断 working_dir**(即 `unilabos_data` 所在目录):
| 条件 | working_dir 取值 |
|------|------------------|
| 条件 | working_dir 取值 |
| -------------------- | -------------------------------------------------------- |
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
**按优先级搜索文件**
@@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config <config.py>
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 — 列出可用设备
@@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
每个 action 生成一个 JSON 文件,包含:
- `type` — 作为 API 调用的 `action_type`
- `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义)
- `goal` — goal 字段映射(含占位符 `$placeholder`
@@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
### Step 3 — 写 action-index.md
按模板为每个 action 写条目:
按模板为每个 action 写条目**必须包含 `action_type`**
```markdown
### `<action_name>`
<用途描述(一句话)>
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
- **可选参数**: `param3`, `param4`
@@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
```
描述规则:
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
-`schema.properties` 读参数列表schema 已提升为 goal 内容)
-`schema.required` 区分核心/可选参数
- 按功能分类(移液、枪头、外设等)
@@ -158,12 +144,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- `unilabos_devices`**DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device
- `unilabos_nodes`**NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
- `unilabos_class`**ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
- `unilabos_formulation`**FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`well_name 为目标物料的 name
- array 类型字段 → `[{id, name, uuid}, ...]`
- 特殊:`create_resource``res_id`ResourceSlot可填不存在的路径
### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板10 个 endpoint,修改:
直接复用 `unilab-device-api` 的 API 模板,修改:
- 设备名称
- Action 数量
- 目录列表
@@ -171,43 +159,96 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot物料/设备/节点/类名)
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
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}
- Windows 平台必须用 curl.exe非 PowerShell 的 curl 别名)
## Session State
- lab_uuid通过 GET /edge/lab/info 直接获取,不要问用户), device_name
## API Endpoints
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
# - #3 创建节点 POST /edge/workflow/node
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
# - #4 删除节点 DELETE /lab/workflow/nodes
# - #5 更新节点参数 PATCH /lab/workflow/node
# - #6 查询节点 handles POST /lab/workflow/node-handles
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
# - #7 批量创建边 POST /lab/workflow/edges
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
# - #9 运行设备单动作 POST /lab/mcp/run/action action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
# 返回 workflow 完整结构data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
# 返回 res_template_uuid用于 #15 创建物料时的必填字段
# - #15 创建物料节点 POST /edge/material/node
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
# - #16 更新物料节点 PUT /edge/material/node
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
## 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" 字符串
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
- 特例create_resource 的 res_id 允许填不存在的路径
- 列出本设备所有 Slot 字段、类型及含义
## 渐进加载策略
## 完整工作流 Checklist
```
### Step 5 — 验证
检查文件完整性:
- [ ] `SKILL.md` 包含 10 个 API endpoint
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `action-index.md` 列出所有 action 并有描述
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
@@ -249,71 +290,202 @@ API 模板结构:
```
> **注意**`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段PATCH 更新时直接修改 `param` 即可
## Placeholder Slot 类型体系
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
| 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 |
| 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 |
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
### ResourceSlot`unilabos_resources`
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
> **特例**`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
### DeviceSlot / NodeSlot / ClassSlot
- **DeviceSlot**`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
- **NodeSlot**`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
- **ClassSlot**`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
### FormulationSlot`unilabos_formulation`
描述**液体配方**:向哪些容器中加入哪些液体及体积。
```json
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
[
{
"sample_uuid": "",
"well_name": "bottle_A1",
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
}
]
```
- 单个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` = 目标位置)
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`体积单位由上下文决定pylabrobot 内部统一 uL
- `sample_uuid` — 样品 UUID无样品传 `""`
- 与 ResourceSlot 的区别ResourceSlot 指向物料本身FormulationSlot 引用物料名并附带配方信息
> **特例**`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 获取资源树
### 通过 API #12 获取资源树
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name``uuid``type``parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
```json
{
"code": 0,
"data": {
"nodes": [
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
],
"edges": [...]
}
}
```
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
- `type` 区分设备(`device`)和物料(`deck``container``resource` 等)
- `parent` 为父节点名称(空字符串表示顶级)
- 填写 Slot 时根据 placeholder 类型筛选ResourceSlot 取非 device 节点DeviceSlot 取 device 节点
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
## 物料管理 API
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid``parent_uuid` 均从 **#12 资源树下载** 获取。
### API #14 — 按名称查询物料模板
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
| 参数 | 必填 | 说明 |
| ---------- | ------ | -------------------------------- |
| `lab_uuid` | **是** | 实验室 UUID从 API #1 获取) |
| `name` | **是** | 物料模板名称(如 `"container"` |
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name``resource_type``handles``config_infos` 等模板元信息。
模板不存在时返回 `code: 10002``data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
### API #15 — 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "my_custom_bottle",
"display_name": "自定义瓶子",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"type": "",
"init_param_data": {},
"schema": {},
"data": {
"liquids": [["water", 1000, "uL"]],
"max_volume": 50000
},
"plate_well_datas": {},
"plate_reagent_datas": {},
"pose": {},
"model": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
| `display_name` | 否 | string | 用户自定义 | 显示名称UI 展示用) |
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
| `type` | 否 | string | 从模板继承 | 节点类型 |
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
| `data` | 否 | object | 用户指定 | 节点数据container 见下方 data 格式 |
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
| `schema` | 否 | object | 从模板继承 | 自定义 schema不传则从模板继承 |
| `pose` | 否 | object | 用户指定 | 位姿信息 |
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
#### container 的 `data` 格式
> **体积单位统一为 uL微升**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume均为 uL。外部如果是 mL 需乘 1000 转换。
```json
{
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
"max_volume": 50000
}
```
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
- `max_volume` — 容器最大容量uL如 50 mL = 50000 uL
### API #16 — 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"display_name": "新显示名称",
"description": "新描述",
"init_param_data": {},
"data": {},
"pose": {},
"schema": {},
"extra": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
| `description` | 否 | string | 用户指定 | 更新描述 |
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
| `data` | 否 | object | 用户指定 | 更新节点数据 |
| `pose` | 否 | object | 用户指定 | 更新位姿 |
| `schema` | 否 | object | 用户指定 | 更新 schema |
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
> 只传需要更新的字段,未传的字段保持不变。
## 最终目录结构

View File

@@ -0,0 +1,251 @@
---
name: host-node
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
---
# Host Node API Skill
## 设备信息
- **device_id**: `host_node`
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
- **设备类**: `HostNode`
- **动作数**: 4`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`base64(ak:sk)``Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``host_node`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `host_node`
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `test_latency` | `UniLabJsonCommand` |
| `create_resource` | `ResourceCreateFromOuterEasy` |
| `auto-test_resource` | `UniLabJsonCommand` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | 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"` | 注册表中已注册的资源类 |
### host_node 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ----------- | ------------ | ------------------------------ |
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```

View File

@@ -0,0 +1,58 @@
# Action Index — host_node
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 资源管理
### `create_resource`
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
- **action_type**: `ResourceCreateFromOuterEasy`
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
- **占位符字段**:
- `res_id`**ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
- `device_id`**DeviceSlot**,填路径字符串如 `"/host_node"`
- `parent`**NodeSlot**,填路径字符串如 `"/workstation/deck"`
- `class_name`**ClassSlot**,填类名如 `"container"`
### `auto-test_resource`
测试资源系统,返回当前资源树和设备列表
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
- **可选参数**: `resource`, `resources`, `device`, `devices`
- **占位符字段**:
- `resource`**ResourceSlot**,单个物料节点 `{id, name, uuid}`
- `resources`**ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
- `device`**DeviceSlot**,设备路径字符串
- `devices`**DeviceSlot**,设备路径字符串
---
## 系统工具
### `test_latency`
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
- **参数**: 无(零参数调用)
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
- **占位符字段**: `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -0,0 +1,93 @@
{
"type": "ResourceCreateFromOuterEasy",
"goal": {
"res_id": "res_id",
"class_name": "class_name",
"parent": "parent",
"device_id": "device_id",
"bind_locations": "bind_locations",
"liquid_input_slot": "liquid_input_slot[]",
"liquid_type": "liquid_type[]",
"liquid_volume": "liquid_volume[]",
"slot_on_deck": "slot_on_deck"
},
"schema": {
"type": "object",
"properties": {
"res_id": {
"type": "string"
},
"device_id": {
"type": "string"
},
"class_name": {
"type": "string"
},
"parent": {
"type": "string"
},
"bind_locations": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "bind_locations",
"additionalProperties": false
},
"liquid_input_slot": {
"type": "array",
"items": {
"type": "integer"
}
},
"liquid_type": {
"type": "array",
"items": {
"type": "string"
}
},
"liquid_volume": {
"type": "array",
"items": {
"type": "number"
}
},
"slot_on_deck": {
"type": "string"
}
},
"required": [],
"_unilabos_placeholder_info": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
},
"goal_default": {},
"placeholder_keys": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
}

View File

@@ -0,0 +1,32 @@
{
"type": "UniLabJsonCommand",
"goal": {
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -0,0 +1,11 @@
{
"type": "UniLabJsonCommand",
"goal": {},
"schema": {
"type": "object",
"properties": {},
"required": []
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,255 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"resources": "resources",
"device": "device",
"devices": "devices"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"resources": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resources"
},
"type": "array"
},
"device": {
"type": "string",
"description": "device reference"
},
"devices": {
"type": "string",
"description": "device reference"
}
},
"required": [],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
}

View File

@@ -0,0 +1,284 @@
---
name: submit-agent-result
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
---
# Uni-Lab 提交历史实验记录指南
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token
```bash
# ⚠️ 注意scheme 是 "Lab"Uni-Lab 专用),不是 "Basic"
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
```
输出即为 token 值,拼接为 `Authorization: Lab <token>``Lab` 是 Uni-Lab 平台 auth scheme不可替换为 `Basic`)。
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
# ⚠️ Auth scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
### 3. notebook_uuid**必须询问用户**
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`
如果用户不记得,可提示:
- 查看之前的对话记录中创建 notebook 时返回的 UUID
- 或通过平台页面查找对应的 notebook
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
### 4. 实验结果数据
用户需要提供实验结果数据,支持以下方式:
| 方式 | 说明 |
| --------- | ----------------------------------------------- |
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
**四项全部就绪后才可开始。**
## Session State
在整个对话过程中agent 需要记住以下状态:
- `lab_uuid` — 实验室 UUID通过 API #1 自动获取,**不需要问用户**
- `notebook_uuid` — 目标 notebook UUID**必须询问用户**
## 请求约定
所有请求使用 `curl -s`PUT 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
>
> **PowerShell JSON 传参**PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 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. 提交实验结果agent_result
```bash
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体结构:
```json
{
"notebook_uuid": "<notebook_uuid>",
"agent_result": {
"<key1>": "<value1>",
"<key2>": 123,
"<nested_key>": {"a": 1, "b": 2},
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
}
}
```
> **注意**HTTP 方法是 **PUT**(不是 POST
#### 必要字段
| 字段 | 类型 | 说明 |
| --------------- | ------------- | ------------------------------------------- |
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID从批量提交实验时获取 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
#### agent_result 内容格式
`agent_result` 接受**任意 JSON 对象**,常见格式:
**简单键值对**
```json
{
"avg_rtt_ms": 12.5,
"status": "success",
"test_count": 5
}
```
**包含嵌套结构**
```json
{
"summary": { "total": 100, "passed": 98, "failed": 2 },
"measurements": [
{ "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
{ "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
]
}
```
**从 CSV 文件导入**(脚本自动转换):
```json
{
"experiment_data": [
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
]
}
```
---
## 整合脚本
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
### 用法
```bash
python scripts/prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
```
| 参数 | 必选 | 说明 |
| ----------------- | ---------- | ----------------------------------------------- |
| `--notebook-uuid` | 是 | 目标 notebook UUID |
| `--files` | 是 | 输入文件路径支持多个JSON / CSV |
| `--auth` | 提交时必选 | Lab tokenbase64(ak:sk) |
| `--base` | 提交时必选 | API base URL |
| `--submit` | 否 | 加上此标志则直接提交到云端 |
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json` |
### 文件合并规则
| 文件类型 | 合并方式 |
| --------------------- | -------------------------------------------- |
| `.json`dict | 字段直接合并到 `agent_result` 顶层 |
| `.json`list/other | 以文件名为 key 放入 `agent_result` |
| `.csv` | 以文件名(不含扩展名)为 key值为行对象数组 |
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
### 示例
```bash
# 仅生成请求体文件(不提交)
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json measurements.csv
# 生成并直接提交
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json \
--auth YTFmZDlkNGUt... \
--base https://leap-lab.test.bohrium.com \
--submit
```
---
## 手动构建方式
如果不使用脚本,也可手动构建请求体:
1. 将实验结果数据组装为 JSON 对象
2. 写入临时文件:
```json
{
"notebook_uuid": "<uuid>",
"agent_result": { ... }
}
```
3. 用 curl 提交:
```bash
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '@tmp_body.json'
```
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: **询问用户** notebook_uuid必须不可跳过
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
- [ ] Step 8: 检查返回结果,确认提交成功
```
---
## 常见问题
### Q: notebook_uuid 从哪里获取?
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
### Q: agent_result 有固定的 schema 吗?
没有严格 schema接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
### Q: 可以多次提交同一个 notebook 的结果吗?
可以,后续提交会覆盖之前的 agent_result。
### Q: 认证方式是 Lab 还是 Api
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。

View File

@@ -0,0 +1,133 @@
"""
读取实验结果文件JSON / CSV整合为 agent_result 请求体并可选提交。
用法:
python prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <Lab token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
支持的输入文件格式:
- .json → 直接作为 dict 合并
- .csv → 转为 {"filename": [row_dict, ...]} 格式
"""
import argparse
import base64
import csv
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List
def read_json_file(filepath: str) -> Dict[str, Any]:
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
rows = []
with open(filepath, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
converted = {}
for k, v in row.items():
try:
converted[k] = int(v)
except (ValueError, TypeError):
try:
converted[k] = float(v)
except (ValueError, TypeError):
converted[k] = v
rows.append(converted)
return rows
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
"""将多个文件合并为一个 agent_result dict"""
merged: Dict[str, Any] = {}
for fp in filepaths:
path = Path(fp)
ext = path.suffix.lower()
key = path.stem
if ext == ".json":
data = read_json_file(fp)
if isinstance(data, dict):
merged.update(data)
else:
merged[key] = data
elif ext == ".csv":
merged[key] = read_csv_file(fp)
else:
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
return merged
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
return {
"notebook_uuid": notebook_uuid,
"agent_result": agent_result,
}
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
try:
import requests
except ImportError:
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
sys.exit(1)
url = f"{base}/api/v1/lab/notebook/agent-result"
headers = {
"Content-Type": "application/json",
"Authorization": f"Lab {auth}",
}
resp = requests.put(url, json=body, headers=headers, timeout=30)
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
def main():
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径JSON / CSV")
parser.add_argument("--auth", help="Lab tokenbase64(ak:sk)")
parser.add_argument("--base", help="API base URL")
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
args = parser.parse_args()
for fp in args.files:
if not os.path.exists(fp):
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
sys.exit(1)
agent_result = merge_files(args.files)
body = build_request_body(args.notebook_uuid, agent_result)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(body, f, ensure_ascii=False, indent=2)
print(f"[完成] 请求体已保存: {args.output}")
print(f" notebook_uuid: {args.notebook_uuid}")
print(f" agent_result 字段数: {len(agent_result)}")
print(f" 合并文件数: {len(args.files)}")
if args.submit:
if not args.auth or not args.base:
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
sys.exit(1)
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
result = submit(args.base, args.auth, body)
print(f" HTTP {result['status_code']}")
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,272 @@
---
name: virtual-workbench
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
---
# Virtual Workbench API Skill
## 设备信息
- **device_id**: `virtual_workbench`
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
- **设备类**: `VirtualWorkbench`
- **动作数**: 6`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s独占锁和 3 个加热台(每次加热 60s可并行
### 典型工作流程
1. `prepare_materials` — 生成 A1-A5 物料5 个 output handle
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
3. `start_heating` — 启动加热3 个加热台可并行)
4. `move_to_output` — 加热完成后移到输出位置 Cn
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`base64(ak:sk)``Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``virtual_workbench`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `virtual_workbench`
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `auto-prepare_materials` | `UniLabJsonCommand` |
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
| `auto-start_heating` | `UniLabJsonCommand` |
| `auto-move_to_output` | `UniLabJsonCommand` |
| `transfer` | `UniLabJsonCommandAsync` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | 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"` | 注册表中已注册的资源类 |
### virtual_workbench 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ---------------- | ------------ | -------------------- |
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```
### 典型 5 物料并发加热工作流示例
```
prepare_materials (count=5)
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
```
创建节点时,`prepare_materials` 的 5 个 output handle`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station``heating_station_output``material_number_output` 连接到对应 `start_heating``station_id_input``material_number_input`

View File

@@ -0,0 +1,76 @@
# Action Index — virtual_workbench
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 物料准备
### `auto-prepare_materials`
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
- **可选参数**: `count`(物料数量,默认 5
---
## 机械臂 & 加热台操作
### `auto-move_to_heating_station`
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
- **核心参数**: `material_number`物料编号integer
### `auto-start_heating`
启动指定加热台的加热程序可并行3 个加热台同时工作)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
### `auto-move_to_output`
将加热完成的物料从加热台移动到输出位置 Cn
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
---
## 物料转移
### `transfer`
异步转移物料到目标设备(通过 ROS 资源转移)
- **action_type**: `UniLabJsonCommandAsync`
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`
- **占位符字段**:
- `resource`**ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
- `target_device`**DeviceSlot**,目标设备路径字符串
- `mount_resource`**ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
- **占位符字段**:
- `resource`**ResourceSlot**,物料数组
- `target_device`**DeviceSlot**,目标设备路径
- `mount_resource`**ResourceSlot**,目标孔位数组
- `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -0,0 +1,270 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource",
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
},
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"resource",
"target_device",
"mount_resource",
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -0,0 +1,19 @@
{
"type": "UniLabJsonCommand",
"goal": {
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"material_number": {
"type": "integer"
}
},
"required": [
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,24 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,20 @@
{
"type": "UniLabJsonCommand",
"goal": {
"count": "count"
},
"schema": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"default": 5
}
},
"required": []
},
"goal_default": {
"count": 5
},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,24 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,255 @@
{
"type": "UniLabJsonCommandAsync",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
}
},
"required": [
"resource",
"target_device",
"mount_resource"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
}

188
.cursorignore Normal file
View File

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

11
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,11 @@
## 设备接入
当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`
该指南包含完整的模板和已有设备接口参考。
## 关键规则
- 动作方法的参数名是接口契约,不可重命名
- `status` 字符串必须与同类已有设备一致
- `self.data` 必须在 `__init__` 中预填充所有属性字段
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()`

View File

@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
**获取方式:**
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
![copy_aksk.gif](image/copy_aksk.gif)
@@ -69,7 +69,7 @@ class WSConfig:
# HTTP配置
class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
# ROS配置
class ROSConfig:
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1`
- `test``https://leap-lab.test.bohrium.com/api/v1`
- `uat``https://leap-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | -------------------------------------- | ------------ |
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | --------------------------------------- | ------------ |
| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
**预设环境地址**
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1`
### 4. ROSConfig - ROS 配置
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
```
## 配置文件使用方法
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
```python
class HTTPConfig:
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
```
**环境变量方式:**
```bash
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
```
**命令行方式(推荐):**

1100
docs/ai_guides/add_device.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
# Uni-Lab-OS 设备接入 Agent — 提示词模板
> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。
> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。
---
## 系统提示词模板
以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。
---
### 开始复制 ↓
```
你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。
你能做的事:
- 根据用户描述生成完整的设备驱动代码Python、注册表YAML和实验图文件JSON
- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式
- 诊断已有驱动代码的接口对齐问题
你不能做的事:
- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取)
- 替代真实硬件联调测试
## 知识来源
{{KNOWLEDGE_LOADING}}
## 工作流程
当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。
### 阶段 1设备画像交互
向用户收集以下三个信息,可以一次性提问:
1. **设备类别** — 属于以下哪一种?
- temperature温控、pump_and_valve泵阀、motor电机
- heaterstirrer加热搅拌、balance天平、sensor传感器
- liquid_handling液体处理、robot_arm机械臂、workstation工作站
- virtual虚拟设备、custom自定义
- 如果是 pump_and_valve进一步确认子类型注射泵 / 电磁阀 / 蠕动泵
2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b
3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟)
⏸️ **暂停:等待用户回答后继续**
### 阶段 2指令协议收集交互
根据上一步确定的通信协议,引导用户提供指令信息:
- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑
- 如果用户有 **协议文档**请用户提供文档PDF/图片/文本),你从中解析指令格式
- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令
- 如果是 **标准协议**Modbus 寄存器表、SCPI请用户提供寄存器/指令映射
- 如果是 **虚拟设备**:跳过此阶段
⏸️ **暂停:确认已获取足够的指令协议信息**
### 阶段 3确认摘要
在开始生成代码前,向用户展示你的理解摘要:
```
设备接入摘要:
- 设备名称:<name>
- 设备类别:<category><subtype>
- 通信协议:<protocol>
- 指令来源:<source>
- 将要实现的属性:<list>
- 将要实现的动作:<list>
- 同类已有设备:<existing>(将对齐其接口)
```
⏸️ **暂停:用户确认"没问题"后再生成代码**
### 阶段 4自动生成无需暂停
按以下顺序自动执行:
1. **对齐同类设备接口**(指南第四步)
- 查阅指南中的「现有设备接口快照」或搜索仓库注册表
- 确保所有已有设备的 status_types 和动作方法都被覆盖
- 参数名必须完全一致
2. **生成驱动代码** — `unilabos/devices/<category>/<name>.py`
3. **生成注册表** — `unilabos/registry/devices/<name>.yaml`(最小配置)
4. **生成图文件** — `unilabos/test/experiments/graph_example_<name>.json`
### 阶段 5验证输出
生成完成后,逐项检查对齐验证清单并展示结果:
```
对齐验证清单:
- [x] 所有动作方法的参数名与已有设备完全一致
- [x] status 属性返回的字符串值与已有设备一致
- [x] 已有设备的所有 status_types 字段都有对应 @property
- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法
- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值
- [x] 串口/二进制协议的响应解析先定位帧起始标记
```
如果有未通过的项,主动修复后再展示。
## 硬约束(违反任何一条都会导致设备接入失败)
1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity是接口契约框架通过参数名分派调用。绝不能加后缀如 volume_ml、改名如 speed_ml_s。单位写在 docstring 中。
2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。
3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。
4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。
5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE禁止用硬编码索引直接解析。
6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。
7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位mL、°C、RPM驱动内部负责转换到硬件原始值步数、Hz、寄存器值
## 代码骨架参考
所有设备驱动遵循以下结构:
```python
import logging
import time as time_module
from typing import Dict, Any
try:
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
except ImportError:
BaseROS2DeviceNode = None
class MyDevice:
_ros_node: "BaseROS2DeviceNode"
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
if device_id is None and 'id' in kwargs:
device_id = kwargs.pop('id')
if config is None and 'config' in kwargs:
config = kwargs.pop('config')
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.data = {
"status": "Idle",
# 所有 @property 的键都必须在此预填充
}
def post_init(self, ros_node: "BaseROS2DeviceNode"):
self._ros_node = ros_node
async def initialize(self) -> bool:
self.data["status"] = "Idle"
return True
async def cleanup(self) -> bool:
self.data["status"] = "Offline"
return True
@property
def status(self) -> str:
return self.data.get("status", "Idle")
```
## 注册表最小配置
```yaml
my_device:
class:
module: unilabos.devices.<category>.<file>:MyDevice
type: python
```
启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。
## 图文件模板
```json
{
"nodes": [
{
"id": "my_device_1",
"name": "设备名称",
"children": [],
"parent": null,
"type": "device",
"class": "my_device",
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {}
}
]
}
```
## 现有设备接口快照(对齐用)
对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本:
https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/
### pump_and_valve — 注射泵
已有设备syringe_pump_with_valve.runze.SY03B-T06
属性status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String)
方法签名(参数名不可改):
- initialize()
- set_valve_position(position)
- set_position(position: float, max_velocity: float = None)
- pull_plunger(volume: float)
- push_plunger(volume: float)
- set_max_velocity(velocity: float)
- set_velocity_grade(velocity)
- stop_operation()
### pump_and_valve — 电磁阀
属性status(str), valve_position(str)
方法open(), close(), set_valve_position(position), is_open(), is_closed()
### temperature
属性status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C)
### motor
属性status(str), position(int)
### sensor
属性level(bool), rssi(int)
```
### 结束复制 ↑
---
## `{{KNOWLEDGE_LOADING}}` 变量替换
根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容:
### 方案 A有知识库Custom GPT / Claude Project
```
你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。
执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。
```
### 方案 B有联网能力
```
执行工作流前,从以下 URL 获取完整的设备接入指南:
https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md
该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
如果无法访问 URL使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。
```
### 方案 C无知识库、无联网
```
完整的设备接入指南需要用户在对话中提供。
如果用户未主动提供,请在阶段 1 开始前询问:
"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。"
本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。
但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。
```
---
## 各平台配置指南
### OpenAI Custom GPT
1. 进入 https://chat.openai.com/gpts/editor
2. **Name**Uni-Lab-OS 设备接入助手
3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。
4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
5. **Knowledge**:上传 `docs/ai_guides/add_device.md`
6. **Capabilities**:开启 Code Interpreter用于代码验证
7. **Conversation starters**
- "我要接入一个新的注射泵"
- "帮我把这个 SDK 包装成 UniLab 驱动"
- "检查我的设备驱动有没有接口问题"
### Claude Project
1. 创建新 Project
2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md`
### API AgentLangChain / AutoGen / 自建框架)
```python
system_prompt = """
<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B>
"""
# 如果框架支持工具调用,可注册以下工具:
tools = [
{
"name": "fetch_device_guide",
"description": "获取最新的 Uni-Lab-OS 设备接入指南",
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md"
},
{
"name": "fetch_registry",
"description": "获取最新的设备注册表",
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml"
},
]
```
### Cursor Agent Mode
无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力Grep 搜索注册表、AskQuestion 收集信息等)。
### 纯网页对话ChatGPT / Claude 无 Project
1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C
2. 第二条消息上传或粘贴 `add_device.md`
3. 第三条消息开始描述设备
---
## 维护说明
- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分
- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分
- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分
- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护

View File

@@ -18,13 +18,15 @@ Uni-Lab 开发团队在仓库中提供了 3 个样例:
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`。详细框架说明请参考 {doc}`plc_framework`
---
## 其他工业通信协议CANopen, Ethernet, OPCUA...
【敬请期待】
Uni-Lab 已实现基于 OPC UA 协议的 PLC 接管框架,用于后处理工站等项目。与 Modbus 框架相比OPC UA 框架额外提供了自动节点发现、订阅推送、断线重连等特性。详细说明请参考 {doc}`plc_framework`
其他协议CANopen、EtherCAT 等)【敬请期待】
## 没有接口的老设备老软件:使用 PyWinAuto

View File

@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
```
┌──────────────────────────────────────────────┐
│ Cloud Platform/Self-hosted Platform │
uni-lab.bohrium.com │
leap-lab.bohrium.com │
│ (Resource Management, Task Scheduling, │
│ Monitoring) │
└────────────────────┬─────────────────────────┘
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
```bash
# 测试云端连接
curl https://uni-lab.bohrium.com/api/v1/health
curl https://leap-lab.bohrium.com/api/v1/health
# 测试WebSocket
# 启动Uni-Lab后查看日志

View File

@@ -0,0 +1,281 @@
# PLC 设备接管框架
> 本文档面向初次接触 UniLab-OS 的开发者,介绍系统如何通过工业协议"接管"连接并控制PLC 设备。
## 什么是"PLC 接管"
**PLC**可编程逻辑控制器是工厂设备的控制核心驱动机械臂、泵、阀门等硬件。UniLab-OS 通过网络协议直接读写 PLC 内部变量,从而控制设备运行:
```
UniLab-OSPython ←通信协议→ PLC ←电信号→ 电机/气缸/传感器
```
UniLab-OS 提供两套接管框架,对应两种工业协议:
| 框架 | 协议 | 应用项目 | 核心文件 |
| --------------- | ---------------- | ------------------ | ----------------------------------------------------------- |
| **Modbus 框架** | Modbus TCP / RTU | 扣式电池装配工站 | `unilabos/device_comms/modbus_plc/client.py` |
| **OPC UA 框架** | OPC UA | 后处理工站(怀柔) | `unilabos/devices/workstation/post_process/post_process.py` |
两套框架**设计思想完全一致**,底层通信协议不同。理解一个,另一个基本触类旁通。
---
## 核心概念
### 节点Node
节点是 PLC 内部一个具体的变量地址,可以理解为 PLC 的一个输入/输出端口。
| 属性 | 说明 | 示例 |
| ---- | -------------------------------------- | -------------------- |
| 名称 | 人类可读标识 | `COIL_SYS_START_CMD` |
| 地址 | PLC 内存地址 | `0x0064` |
| 类型 | Coil / HoldRegister / InputRegister 等 | `coil` |
```
PLC 内存空间
├── Coil 区: True / False ← 控制开关量(启动/停止/复位)
├── Hold Reg: 120, 35.5 … ← 存参数值(速度、位置)
└── Input Reg: 99.8, 42 … ← 只读传感器数据
```
### 动作生命周期Action Lifecycle
每个设备动作被拆分为 4 个阶段,用 `try/finally` 保证安全性:
```python
try:
init(...) # 写入参数(速度、位置等)— 可选
start(...) # 发触发信号 + 轮询等待完成
stop(...) # 复位触发信号(成功时执行)
except:
is_err = True
finally:
cleanup(...) # 无论成败都执行,作为安全兜底
```
| 阶段 | 何时执行 | 典型内容 |
| --------- | ----------------------- | ------------------------------------ |
| `init` | 成功路径(可选) | 写运动速度 = 20.0 |
| `start` | 成功路径 | 写启动位 = True等待完成位 = True |
| `stop` | 成功路径 | 写启动位 = False正常复位 |
| `cleanup` | **无论成败**finally | 安全兜底复位,防止异常时设备持续运动 |
> **为什么 `cleanup` 无论成败都执行?**
> 若 `start` 阶段因传感器故障抛出异常,`stop` 会被跳过PLC 触发位仍为 `True`——设备可能持续运动。`cleanup` 放在 `finally` 块中,作为最后的安全保障,确保 PLC 一定被复位到安全状态。实际上大多数动作将 `cleanup` 设为 `null`,由 `stop` 负责正常复位即可。
---
## Modbus 框架
**核心文件**`unilabos/device_comms/modbus_plc/client.py`
**参考实现**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
### 连接与节点注册
```python
from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient
# 1. 建立 TCP 连接
client = TCPClient(addr="172.16.28.102", port=502)
client.client.connect()
# 2. 从 CSV 加载节点定义
nodes = BaseClient.load_csv("coin_cell_assembly_b.csv")
# 3. 注册节点,之后可按名称访问
client.register_node_list(nodes)
# 访问节点
client.use_node('COIL_SYS_START_CMD').write(True)
value, err = client.use_node('COIL_SYS_START_STATUS').read(1)
```
**CSV 格式**`Name` / `DeviceType` / `Address` / `DataType`
| Name | DeviceType | Address | DataType |
| ------------------ | ------------- | ------- | -------- |
| COIL_SYS_START_CMD | coil | 100 | INT16 |
| REG_SPEED | hold_register | 200 | FLOAT32 |
### 三段式接管流程(扣式电池工站)
PLC 设备通常需要按固定顺序切换模式,以扣式电池工站为例:
```
Python PLC
│── 写 HAND_CMD = True ─────────>│ 切换到手动模式
│<─ 读 HAND_STATUS = True ────────│ 确认进入手动
│── 写 INIT_CMD = True ──────────>│ 执行初始化
│<─ 读 INIT_STATUS = True ─────────│ 初始化完成
│── 写 HAND_CMD = False ──────────>│ 复位(脉冲信号)
│── 写 INIT_CMD = False ──────────>│ 复位
│── 写 AUTO_CMD = True ───────────>│ 切换自动模式
│<─ 读 AUTO_STATUS = True ─────────│ 自动模式就绪
│── 写 AUTO_CMD = False ──────────>│ 复位
│── 写 START_CMD = True ──────────>│ 开始运行
│<─ 读 START_STATUS = True ────────│ 运行确认
│── 写 START_CMD = False ──────────>│ 复位
```
> **脉冲信号模式**:命令写 `True` → 等待 PLC 状态位确认 → 命令写回 `False`,这是大多数 PLC 的标准触发方式,而不是保持高电平。
### JSON 配置方式
Modbus 框架支持纯 JSON 配置,无需手写 Python 流程:
```json
{
"register_node_list_from_csv_path": {"path": "M01.csv"},
"create_flow": [
{
"name": "归位",
"action": [{
"address_function_to_create": [
{"func_name": "pos_tip", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": true},
{"func_name": "pos_tip_read", "node_name": "M01_idlepos_coil_r", "mode": "read", "value": 1},
{"func_name": "manual_stop", "node_name": "M01_manual_stop_coil_r","mode": "read", "value": 1}
],
"create_init_function": {"func_name": "idel_init", "node_name": "M01_idlepos_velocity_rw", "mode": "write", "value": 20.0},
"create_start_function": {
"func_name": "idel_position",
"write_functions": ["pos_tip"],
"condition_functions": ["pos_tip_read", "manual_stop"],
"stop_condition_expression": "pos_tip_read[0] and manual_stop[0]"
},
"create_stop_function": {"func_name": "idel_stop", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": false},
"create_cleanup_function": null
}]
}
],
"execute_flow": ["归位"]
}
```
执行:
```python
client.execute_procedure_from_json(json_data)
```
---
## OPC UA 框架
**核心文件**`unilabos/devices/workstation/post_process/post_process.py`
**参考实现**`unilabos/devices/workstation/post_process/opcua_huairou.json`
### 与 Modbus 的主要区别
| 特性 | Modbus | OPC UA |
| ---------- | -------------------- | --------------------------------- |
| 节点发现 | 手动填写 CSV 地址 | **自动遍历**服务器节点树 |
| 数据获取 | 轮询(主动问) | **订阅推送**(有变化时通知) |
| 节点标识 | 数字地址(如 `100` | 字符串 NodeId`ns=2;s=速度` |
| 断线处理 | 无 | **后台监控线程**自动重连 |
| 认证安全 | 无 | 支持用户名/密码 |
| 工作流调用 | 手动调用 | **自动注册为实例方法** |
### 连接与节点发现
```python
from unilabos.devices.workstation.post_process.post_process import OpcUaClient
client = OpcUaClient(
url="opc.tcp://192.168.1.100:4840",
username="admin", # 可选
password="123456", # 可选
config_path="opcua_huairou.json", # 自动加载工作流配置
cache_timeout=5.0, # 节点值缓存 5 秒
subscription_interval=500, # 每 500ms 接收推送
)
# 节点自动通过订阅保持最新值,读取直接查本地缓存
value = client.get_node_value("grab_complete")
```
### JSON 配置方式
```json
{
"register_node_list_from_csv_path": {"path": "opcua_nodes_huairou.csv"},
"create_flow": [
{
"name": "trigger_grab_action",
"description": "触发反应罐及原料罐抓取动作",
"parameters": ["reaction_tank_number", "raw_tank_number"],
"action": [{
"init_function": {
"func_name": "init_grab_params",
"write_nodes": ["reaction_tank_number", "raw_tank_number"]
},
"start_function": {
"func_name": "start_grab",
"write_nodes": {"grab_trigger": true},
"condition_nodes": ["grab_complete"],
"stop_condition_expression": "grab_complete == True",
"timeout_seconds": 999999.0
},
"stop_function": {
"func_name": "stop_grab",
"write_nodes": {"grab_trigger": false}
}
}]
}
]
}
```
配置加载后,工作流自动注册为实例方法:
```python
# 直接调用,传入参数,框架自动写入对应节点
client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3)
```
---
## 新增设备快速上手
### 使用 Modbus 框架
```
1. 从 PLC 工程师处拿到地址表,按格式填写 CSVName/DeviceType/Address/DataType
2. 继承 BaseClient在 __init__ 中连接并注册节点
3. 参考 coin_cell_assembly.py 编写三段式接管函数(手动→初始化→自动→启动)
4. 或直接编写 JSON 配置,调用 execute_procedure_from_json()
```
### 使用 OPC UA 框架
```
1. 确认设备支持 OPC UA拿到服务器 URL格式opc.tcp://IP:PORT
2. 准备 CSV 节点定义文件(可选,也可让框架自动发现)
3. 编写 JSON 配置:定义 parameters、init/start/stop 函数
4. 实例化 OpcUaClient传入 config_path直接调用自动注册的工作流方法
```
---
## 常见问题
**Q: `node {name} is not registered` 报错?**
A: 节点名不在 CSV 或未调用 `register_node_list_from_csv_path()`
**Q: 程序卡死在 `while not status(): sleep(1)`**
A: PLC 未返回预期完成信号。检查PLC 是否在正确运行模式、状态位地址是否正确、PLC 有无报警。
**Q: OPC UA 连接成功但读不到节点?**
A: 检查节点名称是否与服务器显示名一致(区分中英文)。可调用 `_find_nodes()` 打印服务器全部节点。
**Q: 应该选 Modbus 还是 OPC UA**
A: 取决于设备支持的协议,由 PLC 工程师决定。OPC UA 功能更完整,条件允许优先选择。
---
## 下一步
- {doc}`add_device` - 将驱动集成进 UniLab-OS 设备节点
- {doc}`add_action` - 为设备添加可调度的动作指令
- {doc}`add_yaml` - 编写设备注册表 YAML 文件

View File

@@ -17,6 +17,9 @@ developer_guide/http_api.md
developer_guide/networking_overview.md
developer_guide/add_device.md
developer_guide/add_action.md
developer_guide/add_old_device.md
developer_guide/plc_framework.md
developer_guide/add_protocol.md
developer_guide/add_registry.md
developer_guide/add_yaml.md
developer_guide/action_includes.md

View File

@@ -33,11 +33,11 @@
**选择合适的安装包:**
| 安装包 | 适用场景 | 包含组件 |
|--------|----------|----------|
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
| 安装包 | 适用场景 | 包含组件 |
| --------------- | ---------------------------- | --------------------------------------------- |
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
**关键步骤:**
@@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
```
**选择建议:**
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
@@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
#### 2.1 注册实验室账号
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
2. 注册账号并登录
3. 创建新实验室
@@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
#### 5.2 访问 Web 界面
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
#### 5.3 添加设备和物料
@@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**示例场景:** 创建一个简单的液体转移实验
1. **添加工作站(必需):**
- 在"仪器设备"中找到 `work_station`
- 添加 `workstation` x1
2. **添加虚拟转移泵:**
- 在"仪器设备"中找到 `virtual_device`
- 添加 `virtual_transfer_pump` x1
@@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt
```
**为什么使用这种方式?**
- `unilabos-env` 提供 ROS2 核心组件和 uv通过 conda 安装,避免编译)
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
@@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \
**详细步骤:**
1. **需求分析**
- 明确实验流程
- 列出所需设备和物料
- 设计工作流程图
2. **环境搭建**
- 安装 Uni-Lab-OS
- 创建实验室账号
- 准备开发工具IDE、Git
3. **原型验证**
- 使用虚拟设备测试流程
- 验证工作流逻辑
- 调整参数
4. **迭代开发**
- 实现自定义设备驱动(同时撰写单点函数测试)
- 编写注册表
- 单元测试
- 集成测试
5. **测试部署**
- 连接真实硬件
- 空跑测试
- 小规模试验
@@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
#### 14.5 社区支持
- **GitHub Issues**[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
- **官方网站**[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
- **官方网站**[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
---

View File

@@ -626,7 +626,7 @@ unilab
**云端图文件管理**:
1. 登录 https://uni-lab.bohrium.com
1. 登录 https://leap-lab.bohrium.com
2. 进入"设备配置"
3. 创建或编辑配置
4. 保存到云端

View File

@@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
您可以直接跟随 unilabos 的提示进行,无需查阅本节
- **工作目录设置**
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
- 否则使用 `当前目录/unilabos_data` 作为工作目录
- 可通过 `--working_dir` 指定自定义工作目录
@@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
支持多种后端环境:
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
- 自定义地址:直接指定完整 URL
@@ -176,7 +175,7 @@ unilab --config path/to/your/config.py
如果是首次使用,系统会:
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
2. 引导创建配置文件
3. 设置工作目录
@@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
如果提示 "后续运行必须拥有一个实验室",请确保:
- 已在 https://uni-lab.bohrium.com 注册实验室
- 已在 https://leap-lab.bohrium.com 注册实验室
- 正确设置了 `--ak``--sk` 参数
- 配置文件中包含正确的认证信息

View File

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

View File

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

View File

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

View File

@@ -1,296 +0,0 @@
"""
批量转运编译器测试
覆盖单物料退化、刚好一批、多批次、空操作、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"

View File

@@ -1,706 +0,0 @@
"""
全链路集成测试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

View File

@@ -1,538 +0,0 @@
"""
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")

View File

@@ -1,324 +0,0 @@
"""
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"

View File

@@ -1,137 +0,0 @@
"""
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")

View File

@@ -1 +1 @@
__version__ = "0.10.19"
__version__ = "0.11.1"

View File

@@ -12,6 +12,15 @@ from typing import Dict, Any, List
import networkx as nx
import yaml
# Windows 中文系统 stdout 默认 GBK无法编码 banner / emoji 日志中的 Unicode 字符
# 强制 stdout/stderr 用 UTF-8避免 print 触发 UnicodeEncodeError 导致进程崩溃
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except (AttributeError, OSError):
pass
# 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
@@ -233,7 +242,7 @@ def parse_args():
parser.add_argument(
"--addr",
type=str,
default="https://uni-lab.bohrium.com/api/v1",
default="https://leap-lab.bohrium.com/api/v1",
help="Laboratory backend address",
)
parser.add_argument(
@@ -438,10 +447,10 @@ def main():
if args.addr != parser.get_default("addr"):
if args.addr == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
elif args.addr == "uat":
print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1"
elif args.addr == "local":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
@@ -553,13 +562,8 @@ def main():
os._exit(0)
if not BasicConfig.ak or not BasicConfig.sk:
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)
print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
os._exit(1)
graph: nx.Graph
resource_tree_set: ResourceTreeSet
resource_links: List[Dict[str, Any]]

View File

@@ -36,6 +36,9 @@ class HTTPClient:
auth_secret = BasicConfig.auth_secret()
self.auth = auth_secret
info(f"正在使用ak sk作为授权信息[{auth_secret}]")
# 复用 TCP/TLS 连接,避免每次请求重新握手
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
@@ -48,7 +51,7 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material/edge",
json={
"edges": resources,
@@ -75,25 +78,28 @@ class HTTPClient:
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
# dump() 只调用一次,复用给文件保存和 HTTP 请求
nodes_info = [x for xs in resources.dump() for x in xs]
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
body_bytes = _fast_dumps(payload)
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
f.write(_fast_dumps_pretty(payload))
http_headers = {"Content-Type": "application/json"}
if not self.initialized or first_add:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
data=body_bytes,
headers=http_headers,
timeout=60,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
data=body_bytes,
headers=http_headers,
timeout=10,
)
@@ -111,6 +117,7 @@ class HTTPClient:
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else:
logger.error(f"添加物料失败: {response.text}")
logger.trace(f"添加物料失败: {nodes_info}")
for u, n in old_uuids.items():
if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u]
@@ -131,7 +138,7 @@ class HTTPClient:
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
@@ -145,6 +152,7 @@ class HTTPClient:
logger.error(f"查询物料失败: {response.text}")
else:
data = res["data"]["nodes"]
logger.trace(f"resource_tree_get查询到物料: {data}")
return data
else:
logger.error(f"查询物料失败: {response.text}")
@@ -162,14 +170,14 @@ class HTTPClient:
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
@@ -196,7 +204,7 @@ class HTTPClient:
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
response = requests.get(
response = self._session.get(
f"{self.remote_addr}/lab/material",
params={"id": id, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
@@ -237,14 +245,14 @@ class HTTPClient:
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
@@ -274,7 +282,7 @@ class HTTPClient:
with open(file_path, "rb") as file:
files = {"files": file}
logger.info(f"上传文件: {file_path}{scene}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/api/account/file_upload/{scene}",
files=files,
headers={"Authorization": f"Lab {self.auth}"},
@@ -314,7 +322,7 @@ class HTTPClient:
"Content-Type": "application/json",
"Content-Encoding": "gzip",
}
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/resource",
data=compressed_body,
headers=headers,
@@ -348,7 +356,7 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = requests.get(
response = self._session.get(
f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30),
@@ -409,7 +417,7 @@ class HTTPClient:
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/workflow/owner/import",
json=payload,
headers={"Authorization": f"Lab {self.auth}"},

View File

@@ -1113,7 +1113,7 @@ class MessageProcessor:
"task_id": task_id,
"job_id": job_id,
"free": free,
"need_more": need_more,
"need_more": need_more + 1,
},
}
@@ -1253,7 +1253,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10,
"need_more": 10 + 1,
},
}
self.message_processor.send_message(message)
@@ -1269,7 +1269,13 @@ class QueueProcessor:
if not queued_jobs:
return
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
queue_summary = {}
for j in queued_jobs:
key = f"{j.device_id}/{j.action_name}"
queue_summary[key] = queue_summary.get(key, 0) + 1
logger.debug(
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
)
for job_info in queued_jobs:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY
@@ -1286,7 +1292,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10,
"need_more": 10 + 1,
},
}
success = self.message_processor.send_message(message)
@@ -1369,6 +1375,10 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
self._job_running_last_sent: Dict[str, tuple] = {}
self._job_running_debounce_interval: float = 10.0 # 秒
# 设置相互引用
self.message_processor.set_queue_processor(self.queue_processor)
self.message_processor.set_websocket_client(self)
@@ -1468,22 +1478,32 @@ class WebSocketClient(BaseCommunicationClient):
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
return
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
# 拦截最终结果状态,与原版本逻辑一致
if status in ["success", "failed"]:
self._job_running_last_sent.pop(item.job_id, None)
host_node = HostNode.get_instance(0)
if host_node:
# 从HostNode的device_action_status中移除job_id
try:
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
except (KeyError, AttributeError):
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
# 通知队列处理器job完成包括timeout的job
self.queue_processor.handle_job_completed(item.job_id, status)
# 发送job状态消息
# running状态按job_id做debounce内容变化时仍然上报
if status == "running":
now = time.time()
cached = self._job_running_last_sent.get(item.job_id)
if cached is not None:
last_ts, last_data = cached
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
return
self._job_running_last_sent[item.job_id] = (now, feedback_data)
message = {
"action": "job_status",
"data": {
@@ -1499,7 +1519,6 @@ class WebSocketClient(BaseCommunicationClient):
}
self.message_processor.send_message(message)
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
def send_ping(self, ping_id: str, timestamp: float) -> None:

View File

@@ -5,7 +5,6 @@ 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
@@ -32,7 +31,6 @@ 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,

View File

@@ -1,127 +0,0 @@
"""
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),
}

View File

@@ -2,13 +2,20 @@ 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, debug_print
from .utils.logger_util import action_log
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]")

View File

@@ -1,12 +1,14 @@
from functools import partial
import networkx as nx
import logging
from typing import List, Dict, Any, Union
from .utils.vessel_parser import get_vessel, find_connected_stirrer
from .utils.logger_util import action_log, debug_print
from .utils.vessel_parser import get_vessel
from .pump_protocol import generate_pump_protocol_with_rinsing
create_action_log = partial(action_log, prefix="[ADJUST_PH]")
logger = logging.getLogger(__name__)
def debug_print(message):
"""调试输出"""
logger.info(f"[ADJUST_PH] {message}")
def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
"""
@@ -19,6 +21,8 @@ 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"],
@@ -32,13 +36,17 @@ 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:
@@ -53,15 +61,17 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
name_clean
])
debug_print(f"搜索容器: {len(possible_names)} 个候选名称")
debug_print(f"🎯 可能的容器名称 (前5个): {possible_names[:5]}... (共{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()
@@ -69,10 +79,11 @@ 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', {})
@@ -85,15 +96,56 @@ 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
# 列出可用容器帮助调试
available_containers = [node_id for node_id in G.nodes()
if G.nodes[node_id].get('type') == 'container']
debug_print(f"所有匹配方法失败,可用容器: {available_containers}")
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"❌ 所有匹配方法都失败了")
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
@@ -106,30 +158,44 @@ def calculate_reagent_volume(target_ph_value: float, reagent: str, vessel_volume
Returns:
float: 估算的试剂体积 (mL)
"""
debug_print(f"计算试剂体积: pH={target_ph_value}, reagent={reagent}, vessel={vessel_volume}mL")
# 简化的pH调节体积估算
debug_print(f"🧮 计算试剂体积...")
debug_print(f" 📍 目标pH: {target_ph_value}")
debug_print(f" 🧪 试剂: {reagent}")
debug_print(f" 📏 容器体积: {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
volume = vessel_volume * 0.05 # 5%
debug_print(f" 💪 强酸性 (pH<3): 使用 5% 体积")
elif target_ph_value < 5:
volume = vessel_volume * 0.02
volume = vessel_volume * 0.02 # 2%
debug_print(f" 🔸 中酸性 (pH<5): 使用 2% 体积")
else:
volume = vessel_volume * 0.01
volume = vessel_volume * 0.01 # 1%
debug_print(f" 🔹 弱酸性 (pH≥5): 使用 1% 体积")
elif "hydroxide" in reagent.lower() or "naoh" in reagent.lower():
debug_print(f"🧂 检测到碱性试剂")
# 碱性试剂pH越高需要的体积越大
if target_ph_value > 11:
volume = vessel_volume * 0.05
volume = vessel_volume * 0.05 # 5%
debug_print(f" 💪 强碱性 (pH>11): 使用 5% 体积")
elif target_ph_value > 9:
volume = vessel_volume * 0.02
volume = vessel_volume * 0.02 # 2%
debug_print(f" 🔸 中碱性 (pH>9): 使用 2% 体积")
else:
volume = vessel_volume * 0.01
volume = vessel_volume * 0.01 # 1%
debug_print(f" 🔹 弱碱性 (pH≤9): 使用 1% 体积")
else:
# 未知试剂,使用默认值
volume = vessel_volume * 0.01
debug_print(f"估算试剂体积: {volume:.2f}mL")
debug_print(f"❓ 未知试剂类型,使用默认 1% 体积")
debug_print(f"📊 计算结果: {volume:.2f}mL")
return volume
def generate_adjust_ph_protocol(
@@ -154,67 +220,96 @@ 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(f"pH调节协议: vessel={vessel_id}, ph={ph_value}, reagent='{reagent}'")
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)
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)
# 从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")
# 开始处理
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)
vessel_volume = vessel_data.get('max_volume', 100.0) # 默认100mL
debug_print(f"📏 容器最大体积: {vessel_volume}mL")
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)
action_sequence.append(create_action_log(f"找到转移路径: {' -> '.join(path)}", "🛤️"))
debug_print(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("准备启动搅拌器", "🌪️"))
@@ -223,6 +318,7 @@ 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({
@@ -242,18 +338,23 @@ 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)
addition_time = max(30.0, volume * 2.0) # 至少30秒每mL需要2秒
debug_print(f"⏱️ 计算添加时间: {addition_time}s (缓慢注入)")
action_sequence.append(create_action_log(f"设置添加时间: {addition_time:.0f}s (缓慢注入)", "⏱️"))
try:
@@ -276,28 +377,35 @@ 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"] = {}
@@ -315,16 +423,19 @@ 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({
@@ -337,10 +448,14 @@ 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({
@@ -353,7 +468,17 @@ def generate_adjust_ph_protocol(
# 9. 完成总结
total_time = addition_time + stir_time + settling_time
debug_print(f"pH调节协议完成: {len(action_sequence)} 个动作, {total_time:.0f}s, {volume:.2f}mL {reagent}{vessel_id} pH {ph_value}")
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)
# 添加完成日志
summary_msg = f"pH调节协议完成: {vessel_id} → pH {ph_value} (使用 {volume:.2f}mL {reagent})"
@@ -385,18 +510,28 @@ def generate_adjust_ph_protocol_stepwise(
"""
# 🔧 核心修改从字典中提取容器ID
vessel_id = vessel["id"]
debug_print(f"分步pH调节: vessel={vessel_id}, ph={ph_value}, reagent={reagent}, max_volume={max_volume}mL, steps={steps}")
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)
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} 步开始", "🚀"))
# 生成单步协议
@@ -413,10 +548,12 @@ 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",
@@ -426,7 +563,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
@@ -440,7 +577,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
)
@@ -453,7 +590,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
)
@@ -465,7 +602,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
)
@@ -473,7 +610,10 @@ 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),
@@ -481,12 +621,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()

View File

@@ -1,12 +1,4 @@
"""
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(
@@ -25,32 +17,37 @@ def generate_agv_transfer_protocol(
from_repo_id = from_repo_["id"]
to_repo_id = to_repo_["id"]
# 从 G 中查询 AGV 配置
agv_cfg = find_agv_config(G)
device_roles = agv_cfg["device_roles"]
route_table = agv_cfg["route_table"]
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"}'},
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())}")
("zhixing_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")
("AGV", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
("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": nav_device,
"device_id": "zhixing_agv",
"action_name": "send_nav_task",
"action_kwargs": {
"command": route["nav_command"]
"command": wf_list[(from_repo_id, to_repo_id)]["nav_command"]
}
},
{
"device_id": arm_device,
"device_id": "zhixing_ur_arm",
"action_name": "move_pos_task",
"action_kwargs": {
"command": route.get("arm_command", route.get("arm_place", ""))
"command": wf_list[(from_repo_id, to_repo_id)]["arm_command"]
}
}
]

View File

@@ -1,228 +0,0 @@
"""
批量物料转运编译器
将 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 keyJSON 中以逗号分隔字符串表示)
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)]

View File

@@ -1,9 +1,7 @@
from typing import List, Dict, Any
import networkx as nx
from .utils.vessel_parser import get_vessel, find_solvent_vessel, find_connected_heatchill
from .utils.logger_util import debug_print
from .utils.vessel_parser import get_vessel, find_solvent_vessel
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:
@@ -19,23 +17,43 @@ 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,
@@ -43,7 +61,7 @@ def generate_clean_vessel_protocol(
) -> List[Dict[str, Any]]:
"""
生成容器清洗操作的协议序列,复用 pump_protocol 的成熟算法
清洗流程:
1. 查找溶剂容器和废液容器
2. 如果需要加热,启动加热设备
@@ -52,50 +70,63 @@ 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 = []
debug_print(f"开始生成容器清洗协议: vessel={vessel_id}, solvent={solvent}, volume={volume}mL, temp={temp}°C, repeats={repeats}")
print(f"CLEAN_VESSEL: 开始生成容器清洗协议")
print(f" - 目标容器: {vessel} (ID: {vessel_id})")
print(f" - 清洗溶剂: {solvent}")
print(f" - 清洗体积: {volume} mL")
print(f" - 清洗温度: {temp}°C")
print(f" - 重复次数: {repeats}")
# 验证目标容器存在
if vessel_id not in G.nodes():
raise ValueError(f"目标容器 '{vessel_id}' 不存在于系统中")
# 查找溶剂容器
try:
solvent_vessel = find_solvent_vessel(G, solvent)
debug_print(f"找到溶剂容器: {solvent_vessel}")
print(f"CLEAN_VESSEL: 找到溶剂容器: {solvent_vessel}")
except ValueError as e:
raise ValueError(f"无法找到溶剂容器: {str(e)}")
# 查找废液容器
try:
waste_vessel = find_waste_vessel(G)
debug_print(f"找到废液容器: {waste_vessel}")
print(f"CLEAN_VESSEL: 找到废液容器: {waste_vessel}")
except ValueError as e:
raise ValueError(f"无法找到废液容器: {str(e)}")
# 查找加热设备(可选)
heatchill_id = find_connected_heatchill(G, vessel_id)
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
if heatchill_id:
debug_print(f"找到加热设备: {heatchill_id}")
print(f"CLEAN_VESSEL: 找到加热设备: {heatchill_id}")
else:
debug_print(f"未找到加热设备,将在室温下清洗")
# 记录清洗前的容器状态
print(f"CLEAN_VESSEL: 未找到加热设备,将在室温下清洗")
# 🔧 新增:记录清洗前的容器状态
print(f"CLEAN_VESSEL: 记录清洗前容器状态...")
original_liquid_volume = 0.0
if "data" in vessel and "liquid_volume" in vessel["data"]:
current_volume = vessel["data"]["liquid_volume"]
@@ -103,69 +134,79 @@ 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:
debug_print(f"启动加热至 {temp}°C")
print(f"CLEAN_VESSEL: 启动加热至 {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}
"action_name": "wait",
"action_kwargs": {"time": 30} # 等待30秒让温度稳定
}
action_sequence.append(wait_action)
# 第二步:重复清洗操作
for repeat in range(repeats):
debug_print(f"执行第 {repeat + 1}/{repeats} 次清洗")
print(f"CLEAN_VESSEL: 执行第 {repeat + 1} 次清洗")
# 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,
to_vessel=vessel_id, # 🔧 使用 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
@@ -173,48 +214,58 @@ 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
# 2b. 等待清洗作用时间(让溶剂充分清洗容器)
cleaning_wait_time = 60 if temp > 50.0 else 30 # 高温下等待更久
print(f"CLEAN_VESSEL: 等待清洗作用 {cleaning_wait_time}")
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,
from_vessel=vessel_id, # 🔧 使用 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)
@@ -222,30 +273,34 @@ 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:
if repeat < repeats - 1: # 不是最后一次清洗
print(f"CLEAN_VESSEL: 清洗循环间等待")
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"]
@@ -253,17 +308,20 @@ def generate_clean_vessel_protocol(
final_liquid_volume = current_volume[0]
elif isinstance(current_volume, (int, float)):
final_liquid_volume = current_volume
debug_print(f"清洗完成: {len(action_sequence)} 个动作, 体积 {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
print(f"CLEAN_VESSEL: 清洗完成")
print(f" - 清洗前体积: {original_liquid_volume:.2f}mL")
print(f" - 清洗后体积: {final_liquid_volume:.2f}mL")
print(f" - 生成了 {len(action_sequence)} 个动作")
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]]:
"""快速清洗:室温,单次清洗"""
@@ -271,9 +329,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]]:
@@ -282,13 +340,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(
@@ -296,71 +354,96 @@ 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:
debug_print("未找到有机溶剂,跳过有机清洗步骤")
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

View File

@@ -1,19 +1,402 @@
from functools import partial
import networkx as nx
import re
import logging
from typing import List, Dict, Any, Union
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 .utils.vessel_parser import get_vessel
from .utils.logger_util import action_log
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, # 🔧 修改:从字符串改为字典类型
@@ -53,21 +436,43 @@ def generate_dissolve_protocol(
- mol: "0.12 mol", "16.2 mmol"
"""
# 从字典中提取容器ID
# 🔧 核心修改:从字典中提取容器ID
vessel_id, vessel_data = get_vessel(vessel)
debug_print(f"溶解协议: vessel={vessel_id}, solvent='{solvent}', volume={volume}, "
f"mass={mass}, temp={temp}, time={time}")
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)
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"]
@@ -75,16 +480,30 @@ 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"参数解析: vol={final_volume}mL, mass={final_mass}g, temp={final_temp}°C, time={final_time}s")
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("🔍 步骤3: 判断溶解类型...")
action_sequence.append(create_action_log("正在判断溶解类型...", "🔍"))
# 判断是固体溶解还是液体溶解
is_solid_dissolve = (final_mass > 0 or (mol and mol.strip() != "") or (reagent and reagent.strip() != ""))
@@ -96,31 +515,49 @@ 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 "液体溶解"
debug_print(f"溶解类型: {dissolve_type}")
action_sequence.append(create_action_log(f"溶解类型: {dissolve_type}", "📋"))
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("🔍 步骤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"设备: heatchill='{heatchill_id}', stirrer='{stirrer_id}'")
if not stir_device_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:
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,
@@ -136,6 +573,7 @@ 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}
@@ -143,6 +581,7 @@ 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,
@@ -154,8 +593,9 @@ 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}
@@ -163,8 +603,12 @@ 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 = {
@@ -176,27 +620,42 @@ 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
@@ -204,7 +663,10 @@ 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,
@@ -226,9 +688,12 @@ 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"] = {}
@@ -238,14 +703,19 @@ 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():
@@ -262,19 +732,27 @@ 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,
@@ -292,6 +770,7 @@ def generate_dissolve_protocol(
elif stirrer_id:
# 使用定时搅拌
action_sequence.append(create_action_log(f"使用搅拌器进行定时溶解", "🌪️"))
stir_action = {
"device_id": stirrer_id,
@@ -308,6 +787,7 @@ def generate_dissolve_protocol(
else:
# 简单等待
action_sequence.append(create_action_log(f"简单等待溶解完成", ""))
action_sequence.append({
"action_name": "wait",
"action_kwargs": {"time": final_time}
@@ -315,7 +795,9 @@ 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",
@@ -326,7 +808,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({
@@ -347,8 +829,23 @@ def generate_dissolve_protocol(
final_liquid_volume = current_volume
# === 最终结果 ===
debug_print(f"溶解协议完成: {vessel_id}, 类型={dissolve_type}, "
f"动作数={len(action_sequence)}, 体积={original_liquid_volume:.2f}{final_liquid_volume:.2f}mL")
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)
# 添加完成日志
summary_msg = f"溶解协议完成: {vessel_id}"
@@ -357,7 +854,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
@@ -369,7 +866,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,
@@ -382,7 +879,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,
@@ -395,7 +892,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,
@@ -407,7 +904,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,
@@ -420,7 +917,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,
@@ -432,31 +929,37 @@ 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()

View File

@@ -1,40 +1,87 @@
import networkx as nx
from typing import List, Dict, Any
from .utils.vessel_parser import get_vessel, find_connected_heatchill
from .utils.logger_util import debug_print
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
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
dry_time = 3600.0
simulation_time = 60.0
debug_print(f"开始生成干燥协议: vessel={vessel_id}, compound={compound or '未指定'}, temp={dry_temp}°C")
# 记录干燥前的容器状态
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"🔍 记录干燥前容器状态...")
original_liquid_volume = 0.0
if "data" in vessel and "liquid_volume" in vessel["data"]:
current_volume = vessel["data"]["liquid_volume"]
@@ -42,30 +89,39 @@ 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():
debug_print(f"容器 '{vessel_id}' 不存在于系统中,跳过干燥")
print(f"⚠️ DRY: 警告 - 容器 '{vessel_id}' 不存在于系统中,跳过干燥 😢")
return action_sequence
print(f"✅ 容器 '{vessel_id}' 验证通过!")
# 2. 查找相连的加热器
heater_id = find_connected_heatchill(G, vessel_id)
print(f"\n🔍 步骤2: 查找与容器相连的加热器...")
heater_id = find_connected_heater(G, vessel_id) # 🔧 使用 vessel_id
if heater_id is None:
debug_print(f"未找到与容器 '{vessel_id}' 相连的加热器,添加模拟干燥动作")
print(f"😭 DRY: 警告 - 未找到与容器 '{vessel_id}' 相连的加热器,跳过干燥")
print(f"🎭 添加模拟干燥动作...")
# 添加一个等待动作,表示干燥过程(模拟)
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):
@@ -77,14 +133,15 @@ 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
@@ -92,27 +149,33 @@ def generate_dry_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")
debug_print(f"协议生成完成,共 {len(action_sequence)} 个动作")
print(f"📊 模拟干燥体积变化: {original_liquid_volume:.2f}mL {new_volume:.2f}mL (-{volume_loss:.2f}mL)")
print(f"📄 DRY: 协议生成完成,共 {len(action_sequence)} 个动作 🎯")
return action_sequence
debug_print(f"找到加热器: {heater_id}")
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": {
@@ -120,27 +183,34 @@ 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
total_evaporation = min(original_liquid_volume * 0.8,
evaporation_rate * simulation_time)
# 干燥过程中,溶剂会蒸发,固体保留
# 根据温度和时间估算蒸发量
evaporation_rate = 0.001 * dry_temp # 每秒每°C蒸发0.001mL
total_evaporation = min(original_liquid_volume * 0.8,
evaporation_rate * simulation_time) # 最多蒸发80%
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):
@@ -152,14 +222,15 @@ 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
@@ -167,29 +238,37 @@ def generate_dry_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 (-{total_evaporation:.2f}mL)")
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}%")
# 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,
"time": 10.0, # 等待10秒冷却
"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"]
@@ -197,37 +276,60 @@ def generate_dry_protocol(
final_liquid_volume = current_volume[0]
elif isinstance(current_volume, (int, float)):
final_liquid_volume = current_volume
debug_print(f"干燥协议生成完成: {len(action_sequence)} 个动作, 体积 {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
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"🏁 所有动作序列准备就绪! ✨")
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():
"""测试干燥协议"""
debug_print("=== DRY PROTOCOL 测试 ===")
debug_print("测试完成")
print("=== DRY PROTOCOL 测试 ===")
print("测试完成")
if __name__ == "__main__":
test_dry_protocol()
test_dry_protocol()

View File

@@ -3,14 +3,38 @@ 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, find_connected_stirrer
from .utils.logger_util import debug_print, action_log
from .utils.vessel_parser import get_vessel
from .utils.logger_util import 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:
@@ -20,9 +44,10 @@ def find_gas_source(G: nx.DiGraph, gas: str) -> str:
2. 气体类型匹配data.gas_type
3. 默认气源
"""
debug_print(f"正在查找气体 '{gas}' 的气源...")
# 通过容器名称匹配
debug_print(f"🔍 正在查找气体 '{gas}' 的气源...")
# 第一步:通过容器名称匹配
debug_print(f"📋 方法1: 容器名称匹配...")
gas_source_patterns = [
f"gas_source_{gas}",
f"gas_{gas}",
@@ -32,178 +57,254 @@ 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)
# 第二步:通过气体类型匹配 (data.gas_type)
debug_print(f"📋 方法2: 气体类型匹配...")
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]}")
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
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"开始生成抽真空充气协议: vessel={vessel_id}, gas={gas}, repeats={repeats}")
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)
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():
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
debug_print(f"❌ 容器 '{vessel_id}' 在系统中不存在")
raise ValueError(f"容器 '{vessel_id}' 在系统中不存在")
debug_print("✅ 基本参数验证通过")
action_sequence.append(create_action_log("参数验证通过", ""))
# 标准化气体名称
debug_print("🔧 标准化气体名称...")
gas_aliases = {
'n2': 'nitrogen',
'ar': 'argon',
@@ -218,54 +319,61 @@ 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)
stirrer_id = find_connected_stirrer(G, vessel_id) # 🔧 使用 vessel_id
if stirrer_id:
action_sequence.append(create_action_log(f"找到搅拌器: {stirrer_id}", "🌪️"))
else:
action_sequence.append(create_action_log("未找到搅拌器", "⚠️"))
debug_print(f"设备配置: 真空泵={vacuum_pump}, 气源={gas_source}, 搅拌器={stirrer_id}")
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}")
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
@@ -273,6 +381,7 @@ 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
@@ -280,6 +389,7 @@ 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
@@ -287,88 +397,116 @@ 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):
# 验证抽真空路径
if nx.has_path(G, vessel_id, vacuum_pump): # 🔧 使用 vessel_id
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):
# 验证充气路径
if nx.has_path(G, gas_source, vessel_id): # 🔧 使用 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,
from_vessel=vessel_id, # 🔧 使用 vessel_id
to_vessel=vacuum_pump,
volume=VACUUM_VOLUME,
amount="",
@@ -381,25 +519,27 @@ 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} 分钟)", ""))
@@ -407,59 +547,65 @@ 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,
to_vessel=vessel_id, # 🔧 使用 vessel_id
volume=REFILL_VOLUME,
amount="",
time=0.0,
@@ -471,25 +617,27 @@ 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} 分钟)", ""))
@@ -497,26 +645,29 @@ 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",
@@ -524,58 +675,78 @@ 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},}
"action_kwargs": {"vessel": {"id": 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(f"抽真空充气协议生成完成: {len(action_sequence)} 个动作, 预计 {total_time:.0f}s")
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)
# 添加完成日志
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()

View File

@@ -0,0 +1,143 @@
# 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

View File

@@ -4,99 +4,128 @@ 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传入
@@ -106,16 +135,27 @@ 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(f"开始生成蒸发协议: vessel={vessel_id}, pressure={pressure}, temp={temp}, time={time}")
# 记录蒸发前的容器状态
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("🔍 记录蒸发前容器状态...")
original_liquid_volume = 0.0
if "data" in vessel and "liquid_volume" in vessel["data"]:
current_volume = vessel["data"]["liquid_volume"]
@@ -123,97 +163,168 @@ 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 参数不能为空")
# 查找旋转蒸发仪设备
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}分钟)")
# 参数验证和修正
debug_print(f"🎯 时间解析完成: {time} {final_time}s ({final_time/60:.1f}分钟) ⏰✨")
# === 步骤4: 参数验证和修正 ===
debug_print("📍 步骤4: 参数验证和修正... 🔧")
# 修正参数范围
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(f"最终参数: pressure={pressure}bar, temp={temp}°C, time={final_time}s, stir_speed={stir_speed}RPM")
# 蒸发体积计算
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: 蒸发体积计算... 📊")
# 根据温度、真空度、时间和溶剂类型估算蒸发量
evaporation_volume = 0.0
if original_liquid_volume > 0:
base_evap_rate = 0.5
# 基础蒸发速率mL/min
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,
total_evap_rate * (final_time / 60.0)
original_liquid_volume * 0.95, # 最多蒸发95%
total_evap_rate * (final_time / 60.0) # 时间相关的蒸发量
)
debug_print(f"预计蒸发量: {evaporation_volume:.2f}mL ({evaporation_volume/original_liquid_volume*100:.1f}%)")
# 生成动作序列
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: 生成动作序列... 🎬")
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",
@@ -221,17 +332,20 @@ def generate_evaporate_protocol(
"vessel": {"id": target_vessel},
"pressure": float(pressure),
"temp": float(temp),
"time": float(final_time),
"time": float(final_time), # 🔧 强制转换为float类型
"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):
@@ -243,14 +357,15 @@ 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
@@ -258,16 +373,18 @@ 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"]
@@ -275,7 +392,19 @@ def generate_evaporate_protocol(
final_liquid_volume = current_volume[0]
elif isinstance(current_volume, (int, float)):
final_liquid_volume = current_volume
debug_print(f"蒸发协议生成完成: {len(action_sequence)} 个动作, 设备={rotavap_device}, 容器={target_vessel}")
# === 总结 ===
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)
return action_sequence

View File

@@ -2,64 +2,87 @@ 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(f"开始生成过滤协议: vessel={vessel_id}, filtrate_vessel={filtrate_vessel_id}")
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)
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"]
@@ -67,45 +90,79 @@ 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")
# === 参数验证 ===
validate_vessel(G, vessel_id, "过滤容器")
debug_print("📍 步骤1: 参数验证... 🔧")
# 验证必需参数
debug_print(" 🔍 验证必需参数...")
validate_vessel(G, vessel_id, "过滤容器") # 🔧 使用 vessel_id
debug_print(" ✅ 必需参数验证完成 🎯")
# 验证可选参数
debug_print(" 🔍 验证可选参数...")
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)}")
# 过滤体积分配估算
solid_ratio = 0.1
liquid_ratio = 0.9
volume_loss_ratio = 0.05
# 🔧 新增:过滤效率和体积分配估算
debug_print("📍 步骤2.5: 过滤体积分配估算... 📊")
# 估算过滤分离比例(基于经验数据)
solid_ratio = 0.1 # 假设10%是固体(保留在过滤器上)
liquid_ratio = 0.9 # 假设90%是液体(通过过滤器)
volume_loss_ratio = 0.05 # 假设5%体积损失(残留在过滤器等)
# 从kwargs中获取过滤参数进行优化
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:
pass
debug_print("⚠️ 固体含量参数无效,使用默认值")
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}%)")
# === 转移到过滤器(如果需要)===
if vessel_id != filter_device:
debug_print("📍 步骤3: 转移到过滤器... 🚚")
if vessel_id != filter_device: # 🔧 使用 vessel_id
debug_print(f" 🚛 需要转移: {vessel_id}{filter_device} 📦")
try:
debug_print(" 🔄 开始执行转移操作...")
# 使用pump protocol转移液体到过滤器
transfer_actions = generate_pump_protocol_with_rinsing(
G=G,
from_vessel={"id": vessel_id},
from_vessel={"id": vessel_id}, # 🔧 使用 vessel_id
to_vessel={"id": filter_device},
volume=0.0,
volume=0.0, # 转移所有液体
amount="",
time=0.0,
viscous=False,
@@ -116,59 +173,88 @@ 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(f"添加了 {len(transfer_actions)} 个转移动作 🚚✨")
# 🔧 新增:转移后更新容器体积
debug_print(" 🔧 更新转移后的容器体积...")
# 原容器体积变为0所有液体已转移
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(f"转移失败: {str(e)} 😞")
debug_print(" 🔄 继续执行,可能是直接连接的过滤器 🤞")
else:
debug_print(" ✅ 过滤容器就是过滤器,无需转移 🎯")
# === 执行过滤操作 ===
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)
"volume": kwargs.get("volume", 0.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,
@@ -179,15 +265,19 @@ 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:
@@ -196,37 +286,58 @@ 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(f"收集滤液失败: {str(e)} 😞")
debug_print(" 🔄 继续执行,可能滤液直接流入指定容器 🤞")
else:
debug_print(" 🧱 未指定滤液容器,固体保留在过滤器中 🔬")
# 🔧 新增:过滤完成后的容器状态更新
debug_print("📍 步骤5.5: 过滤完成后状态更新... 📊")
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"]
@@ -234,7 +345,22 @@ def generate_filter_protocol(
final_vessel_volume = current_volume[0]
elif isinstance(current_volume, (int, float)):
final_vessel_volume = current_volume
debug_print(f"过滤协议生成完成: {len(action_sequence)} 个动作, 容器={vessel_id}, 过滤器={filter_device}")
# === 总结 ===
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)
return action_sequence

View File

@@ -1,24 +1,118 @@
from typing import List, Dict, Any, Union
import networkx as nx
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
import logging
import re
from .utils.vessel_parser import get_vessel
from .utils.unit_parser import parse_time_input
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(
@@ -37,7 +131,7 @@ def generate_heat_chill_protocol(
) -> List[Dict[str, Any]]:
"""
生成加热/冷却操作的协议序列 - 支持vessel字典
Args:
G: 设备图
vessel: 容器字典从XDL传入
@@ -51,58 +145,82 @@ 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(f"开始生成加热冷却协议: vessel={vessel_id}, temp={temp}°C, "
f"time={time}, stir={stir} ({stir_speed} RPM), purpose='{purpose}'")
# 参数验证
if not vessel_id:
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 参数不能为空! 😱")
raise ValueError("vessel 参数不能为空")
if vessel_id not in G.nodes():
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
debug_print(f"❌ 容器 '{vessel_id}' 不存在于系统中! 😞")
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
# 参数解析
# 温度解析:优先使用 temp_spec
final_temp = parse_temperature_input(temp_spec, temp) if temp_spec else temp
debug_print("✅ 基础参数验证通过 🎯")
# 🔄 参数解析
debug_print("📍 步骤2: 参数解析... ⚡")
#温度解析:优先使用 temp_spec
final_temp = parse_temp_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(f"🎯 最终参数: temp={final_temp}°C, time={final_time}s, stir_speed={stir_speed} RPM")
# 🔍 查找设备
debug_print("📍 步骤3: 查找加热设备... 🔍")
try:
heatchill_id = find_connected_heatchill(G, vessel_id)
debug_print(f"使用加热设备: {heatchill_id}")
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 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}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}分钟) 保持不变 🎯")
action_sequence = []
heatchill_action = {
"device_id": heatchill_id,
"action_name": "heat_chill",
"action_kwargs": {
"vessel": {"id": vessel_id},
"vessel": {"id": vessel},
"temp": float(final_temp),
"time": float(final_time),
"stir": bool(stir),
@@ -111,10 +229,21 @@ def generate_heat_chill_protocol(
}
}
action_sequence.append(heatchill_action)
debug_print(f"加热冷却协议生成完成: {len(action_sequence)} 个动作, "
f"vessel={vessel_id}, temp={final_temp}°C, time={final_time}s")
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)
return action_sequence
def generate_heat_chill_to_temp_protocol(
@@ -126,7 +255,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(
@@ -137,19 +266,21 @@ def generate_heat_chill_start_protocol(
**kwargs
) -> List[Dict[str, Any]]:
"""生成开始加热操作的协议序列"""
# 🔧 核心修改从字典中提取容器ID
vessel_id, _ = get_vessel(vessel)
debug_print(f"生成启动加热协议: vessel={vessel_id}, temp={temp}°C")
debug_print("🔥 开始生成启动加热协议")
debug_print(f"🥽 vessel: {vessel} (ID: {vessel_id}), 🌡️ temp: {temp}°C")
# 基础验证
if not vessel_id or vessel_id not in G.nodes():
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
debug_print("❌ 容器验证失败!")
raise ValueError("vessel 参数无效")
# 查找设备
heatchill_id = find_connected_heatchill(G, vessel_id)
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
# 生成动作
action_sequence = [{
"device_id": heatchill_id,
@@ -160,8 +291,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(
@@ -170,19 +301,21 @@ def generate_heat_chill_stop_protocol(
**kwargs
) -> List[Dict[str, Any]]:
"""生成停止加热操作的协议序列"""
# 🔧 核心修改从字典中提取容器ID
vessel_id, _ = get_vessel(vessel)
debug_print(f"生成停止加热协议: vessel={vessel_id}")
debug_print("🛑 开始生成停止加热协议")
debug_print(f"🥽 vessel: {vessel} (ID: {vessel_id})")
# 基础验证
if not vessel_id or vessel_id not in G.nodes():
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
debug_print("❌ 容器验证失败!")
raise ValueError("vessel 参数无效")
# 查找设备
heatchill_id = find_connected_heatchill(G, vessel_id)
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
# 生成动作
action_sequence = [{
"device_id": heatchill_id,
@@ -190,6 +323,6 @@ def generate_heat_chill_stop_protocol(
"action_kwargs": {
}
}]
debug_print(f"停止加热协议生成完成")
debug_print(f"停止加热协议生成完成 🎯")
return action_sequence

View File

@@ -1,50 +1,105 @@
import networkx as nx
from typing import List, Dict, Any, Optional
from .utils.vessel_parser import get_vessel
from .utils.logger_util import debug_print
from .utils.unit_parser import parse_temperature_input, parse_time_input
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小时
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']
@@ -57,38 +112,40 @@ 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)
debug_print(f"找到的{device_type}节点: {device_nodes}")
print(f"HYDROGENATE: 找到的{device_type}节点: {device_nodes}")
# 检查是否有设备与目标容器相连
for device in device_nodes:
if G.has_edge(device, vessel) or G.has_edge(vessel, device):
debug_print(f"找到与容器 '{vessel}' 相连的{device_type}: {device}")
print(f"HYDROGENATE: 找到与容器 '{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个中间节点
debug_print(f"找到距离较近的{device_type}: {device}")
print(f"HYDROGENATE: 找到距离较近的{device_type}: {device}")
return device
except nx.NetworkXNoPath:
continue
debug_print(f"未找到与容器 '{vessel}' 相连的{device_type}")
print(f"HYDROGENATE: 未找到与容器 '{vessel}' 相连的{device_type}")
return None
@@ -101,31 +158,36 @@ 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_input(temp)
reaction_time = parse_time_input(time)
debug_print(f"开始生成氢化反应协议: vessel={vessel_id}, "
f"temp={temperature}°C, time={reaction_time/3600:.1f}h")
# 记录氢化前的容器状态
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)
# 🔧 新增:记录氢化前的容器状态(可选,氢化反应通常不改变体积)
original_liquid_volume = 0.0
if "data" in vessel and "liquid_volume" in vessel["data"]:
current_volume = vessel["data"]["liquid_volume"]
@@ -133,36 +195,47 @@ 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. 验证目标容器存在
if vessel_id not in G.nodes():
debug_print(f"⚠️ 容器 '{vessel_id}' 不存在于系统中,跳过氢化反应")
print("📍 步骤1: 验证目标容器...")
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
print(f"⚠️ HYDROGENATE: 警告 - 容器 '{vessel_id}' 不存在于系统中,跳过氢化反应")
return action_sequence
print(f"✅ 容器 '{vessel_id}' 验证通过")
# 2. 查找相连的设备
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 '未找到'}")
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 '未找到'}")
# 3. 启动搅拌器
print("📍 步骤3: 启动搅拌器...")
if stirrer_id:
print(f"🌪️ 启动搅拌器 {stirrer_id}")
action_sequence.append({
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": {"id": vessel_id},
"vessel": vessel_id, # 🔧 使用 vessel_id
"stir_speed": 300.0,
"purpose": "氢化反应: 开始搅拌"
}
})
print("✅ 搅拌器启动动作已添加")
else:
debug_print(f"⚠️ 未找到搅拌器,继续执行")
print(f"⚠️ HYDROGENATE: 警告 - 未找到搅拌器,继续执行")
# 4. 启动气源(氢气)
print("📍 步骤4: 启动氢气源...")
if gas_source_id:
print(f"💨 启动气源 {gas_source_id} (氢气)")
action_sequence.append({
"device_id": gas_source_id,
"action_name": "set_status",
@@ -170,10 +243,11 @@ 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",
@@ -181,10 +255,12 @@ def generate_hydrogenate_protocol(
"command": "OPEN"
}
})
print("✅ 氢气源启动动作已添加")
else:
debug_print(f"⚠️ 未找到气源,继续执行")
print(f"⚠️ HYDROGENATE: 警告 - 未找到气源,继续执行")
# 5. 等待气体稳定
print("📍 步骤5: 等待气体环境稳定...")
action_sequence.append({
"action_name": "wait",
"action_kwargs": {
@@ -192,19 +268,22 @@ 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": {"id": vessel_id},
"vessel": vessel_id, # 🔧 使用 vessel_id
"temp": temperature,
"purpose": f"氢化反应: 加热到 {temperature}°C"
}
})
# 等待温度稳定
action_sequence.append({
"action_name": "wait",
@@ -213,38 +292,52 @@ def generate_hydrogenate_protocol(
"description": f"等待温度稳定到 {temperature}°C"
}
})
# 模拟运行时间优化
# 🕐 模拟运行时间优化
print(" ⏰ 检查模拟运行时间限制...")
original_reaction_time = reaction_time
simulation_time_limit = 60.0
simulation_time_limit = 60.0 # 模拟运行时间限制60秒
if reaction_time > simulation_time_limit:
reaction_time = simulation_time_limit
debug_print(f"模拟运行优化: {original_reaction_time}s → {reaction_time}s")
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}分钟) 保持不变")
# 保持反应温度
action_sequence.append({
"device_id": heater_id,
"action_name": "heat_chill",
"action_kwargs": {
"vessel": {"id": vessel_id},
"vessel": 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:
debug_print(f"⚠️ 未找到加热器,使用室温反应")
# 室温反应也需要时间优化
print(f"⚠️ HYDROGENATE: 警告 - 未找到加热器,使用室温反应")
# 🕐 室温反应也需要时间优化
print(" ⏰ 检查室温反应模拟时间限制...")
original_reaction_time = reaction_time
simulation_time_limit = 60.0
simulation_time_limit = 60.0 # 模拟运行时间限制60秒
if reaction_time > simulation_time_limit:
reaction_time = simulation_time_limit
debug_print(f"室温反应时间优化: {original_reaction_time}s → {reaction_time}s")
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 保持不变")
# 室温反应,只等待时间
action_sequence.append({
"action_name": "wait",
@@ -253,19 +346,28 @@ 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": {"id": vessel_id},
"vessel": vessel_id, # 🔧 使用 vessel_id
"purpose": "氢化反应完成,停止加热"
}
})
print("✅ 停止加热动作已添加")
# 8. 等待冷却
print("📍 步骤8: 等待冷却...")
action_sequence.append({
"action_name": "wait",
"action_kwargs": {
@@ -273,12 +375,15 @@ 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",
@@ -286,7 +391,7 @@ def generate_hydrogenate_protocol(
"command": "CLOSED"
}
})
# 再关闭气源
action_sequence.append({
"device_id": gas_source_id,
@@ -295,24 +400,59 @@ 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": {"id": vessel_id},
"vessel": vessel_id, # 🔧 使用 vessel_id
"purpose": "氢化反应完成,停止搅拌"
}
})
# 氢化完成后的状态(氢化反应通常不改变体积)
final_liquid_volume = original_liquid_volume
print("✅ 停止搅拌动作已添加")
# 🔧 新增:氢化完成后的状态(氢化反应通常不改变体积)
final_liquid_volume = original_liquid_volume # 氢化反应体积基本不变
# 总结
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")
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)
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()

View File

@@ -2,18 +2,99 @@ 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 .utils.logger_util import debug_print
from .utils.vessel_parser import get_vessel
from .utils.resource_helper import get_resource_liquid_volume
from unilabos.compile.utils.vessel_parser import get_vessel
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:
"""
判断是否为泵阀一体设备
@@ -41,77 +122,108 @@ 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跳
pump_nodes = [
node_id for node_id in G.nodes()
if "pump" in (G.nodes[node_id].get("class", "") or "").lower()
]
debug_print(f" - 未找到直接相连的泵,尝试路径查找...")
# 获取所有泵节点
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)
if len(path) - 1 <= 2: # 最多允许2跳
path_length = len(path) - 1
debug_print(f" - 到泵 {pump_node} 的路径: {path}, 距离: {path_length}")
if path_length <= 2: # 最多允许2跳
debug_print(f" ✅ 通过路径找到泵: {pump_node}")
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
except ValueError:
debug_print(f" - 阀门 {node} -> 泵 {pump_node}")
except ValueError as e:
debug_print(f" - 跳过节点 {node}: {str(e)}")
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
@@ -124,8 +236,8 @@ def generate_pump_protocol(
transfer_flowrate: float = 0.5,
) -> List[Dict[str, Any]]:
"""
生成泵操作的动作序列
正确处理包含电磁阀的路径
生成泵操作的动作序列 - 修复版本
🔧 修复:正确处理包含电磁阀的路径
"""
pump_action_sequence = []
nodes = G.nodes(data=True)
@@ -144,6 +256,7 @@ 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
@@ -159,24 +272,28 @@ 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:
@@ -192,7 +309,7 @@ def generate_pump_protocol(
debug_print("PUMP_TRANSFER: 没有可用的泵映射")
return pump_action_sequence
# 安全地获取最小转移体积
# 🔧 修复:安全地获取最小转移体积
try:
min_transfer_volumes = []
for node in pump_backbone:
@@ -222,19 +339,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}, 单次最大 {min_transfer_volume} mL")
logger.info(f"分批转移: 总体积 {volume:.2f}mL, {repeats} 次转移")
debug_print(f"🔄 分批转移概览: 总体积 {volume:.2f}mL,需要 {repeats}转移")
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 # 自定义字段,用于进度日志
}
}
@@ -242,12 +359,12 @@ def generate_pump_protocol(
for i in range(repeats):
current_volume = min(volume_left, min_transfer_volume)
# 🆕 在每次循环开始时添加进度日志
if repeats > 1:
pump_action_sequence.append(create_progress_log_action(
f"{i + 1}/{repeats} 次转移: {current_volume:.2f}mL ({from_vessel_id} -> {to_vessel_id})"
))
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))
# 安全地获取边数据
# 🔧 修复:安全地获取边数据
def get_safe_edge_data(node_a, node_b, key):
try:
edge_data = G.get_edge_data(node_a, node_b)
@@ -350,13 +467,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))
@@ -398,205 +515,300 @@ def generate_pump_protocol_with_rinsing(
to_vessel_id, _ = get_vessel(to_vessel)
with generate_pump_protocol_with_rinsing._lock:
debug_print(f"PUMP_TRANSFER: {from_vessel_id} -> {to_vessel_id}, volume={volume}, flowrate={flowrate}")
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)
# 短暂延迟,避免快速重复调用
time_module.sleep(0.01)
debug_print("🔍 步骤1: 开始体积处理...")
# 1. 处理体积参数
final_volume = volume
debug_print(f"📋 初始设置: final_volume = {final_volume}")
# 如果volume为0从容器读取实际体积
# 🔧 修复:如果volume为0ROS2传入的空值,从容器读取实际体积
if volume == 0.0:
debug_print("🎯 检测到 volume=0.0,开始自动体积检测...")
actual_volume = get_resource_liquid_volume(G.nodes.get(from_vessel_id, {}))
# 直接从源容器读取实际体积
actual_volume = get_vessel_liquid_volume(G, from_vessel_id)
debug_print(f"📖 从容器 '{from_vessel_id}' 读取到体积: {actual_volume}mL")
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")
final_volume = 10.0 # 如果读取失败,使用默认值
logger.warning(f"⚠️ 无法从容器读取体积,使用默认值: {final_volume}mL")
else:
debug_print(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":
actual_volume = get_resource_liquid_volume(G.nodes.get(from_vessel_id, {}))
debug_print("🎯 检测到 amount='all',从容器读取全部体积...")
actual_volume = get_vessel_liquid_volume(G, 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")
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")
# 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"速度规格 '{rate_spec}': flowrate={final_flowrate}, transfer={final_transfer_flowrate}")
debug_print(f" - quickly模式流速调整为: {final_flowrate}mL/s")
# 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(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}")
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: 开始执行基础转移...")
# 执行基础转移
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"基础转移生成了 {len(pump_action_sequence)} 个动作")
debug_print(f" - generate_pump_protocol 返回结果:")
debug_print(f" - 动作序列长度: {len(pump_action_sequence)}")
debug_print(f" - 动作序列是否为空: {len(pump_action_sequence) == 0}")
if not pump_action_sequence:
debug_print("基础转移协议为空")
debug_print("基础转移协议生成为空,可能是路径问题")
debug_print(f" - 源容器存在: {from_vessel_id in G.nodes()}")
debug_print(f" - 目标容器存在: {to_vessel_id in G.nodes()}")
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:
pass
debug_print(f" - 路径存在: {path}")
except Exception as path_error:
debug_print(f" - 无法找到路径: {str(path_error)}")
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)}\n{traceback.format_exc()}")
debug_print(f"基础转移失败: {str(e)}")
import traceback
debug_print(f"详细错误: {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"跳过冲洗 (solvent='{final_rinsing_solvent}', repeats={final_rinsing_repeats})")
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"PUMP_TRANSFER 完成: {from_vessel_id} -> {to_vessel_id}, "
f"volume={final_volume}mL, 动作数={len(pump_action_sequence)}")
# ========== 最终结果 ==========
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}")
# 最终验证
if len(pump_action_sequence) == 0:
debug_print("🚨 协议生成结果为空!这是异常情况")
return [
{
"device_id": "system",
"action_name": "log_message",
"action_kwargs": {
"message": "协议生成失败: 无法生成任何动作序列"
"message": f"🚨 协议生成失败: 无法生成任何动作序列"
}
}
]
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:
return volume * 1000
final_volume = volume * 1000
debug_print(f" - 单位: L最终体积: {final_volume}mL")
return final_volume
elif 'μl' in amount or 'microliter' in amount:
return volume / 1000
final_volume = volume / 1000
debug_print(f" - 单位: μL最终体积: {final_volume}mL")
return final_volume
else:
return volume # 默认mL
debug_print(f" - 无单位,假设为 mL: {volume}")
return volume
debug_print(" - 无法解析,返回 0.0")
return 0.0

View File

@@ -4,64 +4,76 @@ 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
debug_print(f"无法解析比例 '{ratio_str}',使用默认比例 1:1")
# 默认 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传入
@@ -70,18 +82,28 @@ 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(f"开始生成重结晶协议: vessel={vessel_id}, ratio={ratio}, solvent1={solvent1}, solvent2={solvent2}, volume={volume}")
# 记录重结晶前的容器状态
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("🔍 记录重结晶前容器状态...")
original_liquid_volume = 0.0
if "data" in vessel and "liquid_volume" in vessel["data"]:
current_volume = vessel["data"]["liquid_volume"]
@@ -89,73 +111,102 @@ 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. 验证目标容器存在
if vessel_id not in G.nodes():
debug_print("📍 步骤1: 验证目标容器... 🔧")
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
debug_print(f"❌ 目标容器 '{vessel_id}' 不存在于系统中! 😱")
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, {solvent2}={volume2:.2f}mL")
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 🎯")
# 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)
path1 = nx.shortest_path(G, source=solvent1_vessel, target=vessel_id) # 🔧 使用 vessel_id
debug_print(f" 🛤️ 溶剂1路径: {''.join(path1)}")
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)
path2 = nx.shortest_path(G, source=solvent2_vessel, target=vessel_id) # 🔧 使用 vessel_id
debug_print(f" 🛤️ 溶剂2路径: {''.join(path2)}")
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,
volume=volume1,
to_vessel=vessel_id, # 🔧 使用 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后
# 🔧 新增:更新容器体积 - 添加溶剂1后
debug_print(" 🔧 更新容器体积 - 添加溶剂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):
@@ -165,14 +216,15 @@ 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
@@ -180,42 +232,53 @@ 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,
volume=volume2,
to_vessel=vessel_id, # 🔧 使用 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后
# 🔧 新增:更新容器体积 - 添加溶剂2后
debug_print(" 🔧 更新容器体积 - 添加溶剂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):
@@ -225,14 +288,15 @@ 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
@@ -240,25 +304,36 @@ 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. 等待重结晶完成
original_crystallize_time = 600.0
simulation_time_limit = 60.0
debug_print("📍 步骤9: 等待重结晶完成... 💎")
# 模拟运行时间优化
debug_print(" ⏱️ 检查模拟运行时间限制...")
original_crystallize_time = 600.0 # 原始重结晶时间
simulation_time_limit = 60.0 # 模拟运行时间限制60秒
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}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 保持不变 🎯")
action_sequence.append({
"action_name": "wait",
"action_kwargs": {
@@ -266,28 +341,50 @@ def generate_recrystallize_protocol(
"description": f"等待重结晶完成({solvent1}:{solvent2} = {ratio},总体积 {final_volume}mL" + (f" (模拟时间)" if original_crystallize_time != final_crystallize_time else "")
}
})
debug_print(f"重结晶协议生成完成: {len(action_sequence)} 个动作, 容器={vessel_id}, 体积变化: {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
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)
return action_sequence
# 测试函数
def test_recrystallize_protocol():
"""测试重结晶协议"""
debug_print("=== RECRYSTALLIZE PROTOCOL 测试 ===")
debug_print("🧪 === RECRYSTALLIZE PROTOCOL 测试 ===")
# 测试体积解析
debug_print("💧 测试体积解析...")
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(f" 📊 体积 '{vol}' -> {parsed}mL")
# 测试比例解析
debug_print("⚖️ 测试比例解析...")
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()

View File

@@ -1,87 +1,253 @@
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__)
create_action_log = action_log
# 确保输出编码为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]}...")
def generate_reset_handling_protocol(
G: nx.DiGraph,
solvent: str,
vessel: Optional[str] = None,
**kwargs
vessel: Optional[str] = None, # 🆕 新增可选vessel参数
**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
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")
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)
# 添加初始日志
action_sequence.append(action_log(f"开始重置处理操作 - 容器: {target_vessel}", "🎬"))
action_sequence.append(action_log(f"使用溶剂: {solvent}", "🧪"))
action_sequence.append(action_log(f"重置体积: {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", "💧"))
if vessel is None:
action_sequence.append(action_log("使用默认目标容器: main_reactor", "⚙️"))
action_sequence.append(create_action_log("使用默认目标容器: main_reactor", "⚙️"))
else:
action_sequence.append(action_log(f"使用指定目标容器: {vessel}", "🎯"))
action_sequence.append(create_action_log(f"使用指定目标容器: {vessel}", "🎯"))
# 1. 验证目标容器存在
action_sequence.append(action_log("正在验证目标容器...", "🔍"))
debug_print("🔍 步骤1: 验证目标容器...")
action_sequence.append(create_action_log("正在验证目标容器...", "🔍"))
if target_vessel not in G.nodes():
action_sequence.append(action_log(f"目标容器 '{target_vessel}' 不存在", ""))
debug_print(f"目标容器 '{target_vessel}' 不存在于系统中!")
action_sequence.append(create_action_log(f"目标容器 '{target_vessel}' 不存在", ""))
raise ValueError(f"目标容器 '{target_vessel}' 不存在于系统中")
action_sequence.append(action_log(f"目标容器验证通过: {target_vessel}", ""))
debug_print(f"目标容器 '{target_vessel}' 验证通过")
action_sequence.append(create_action_log(f"目标容器验证通过: {target_vessel}", ""))
# 2. 查找溶剂容器
action_sequence.append(action_log("正在查找溶剂容器...", "🔍"))
debug_print("🔍 步骤2: 查找溶剂容器...")
action_sequence.append(create_action_log("正在查找溶剂容器...", "🔍"))
try:
solvent_vessel = find_solvent_vessel(G, solvent)
debug_print(f"找到溶剂容器: {solvent_vessel}")
action_sequence.append(action_log(f"找到溶剂容器: {solvent_vessel}", ""))
debug_print(f"找到溶剂容器: {solvent_vessel}")
action_sequence.append(create_action_log(f"找到溶剂容器: {solvent_vessel}", ""))
except ValueError as e:
action_sequence.append(action_log(f"溶剂容器查找失败: {str(e)}", ""))
debug_print(f"溶剂容器查找失败: {str(e)}")
action_sequence.append(create_action_log(f"溶剂容器查找失败: {str(e)}", ""))
raise ValueError(f"无法找到溶剂 '{solvent}': {str(e)}")
# 3. 验证路径存在
action_sequence.append(action_log("正在验证传输路径...", "🛤️"))
debug_print("🔍 步骤3: 验证传输路径...")
action_sequence.append(create_action_log("正在验证传输路径...", "🛤️"))
try:
path = nx.shortest_path(G, source=solvent_vessel, target=target_vessel)
action_sequence.append(action_log(f"传输路径: {''.join(path)}", "🛤️"))
debug_print(f"✅ 找到路径: {''.join(path)}")
action_sequence.append(create_action_log(f"传输路径: {''.join(path)}", "🛤️"))
except nx.NetworkXNoPath:
action_sequence.append(action_log(f"路径不可达: {solvent_vessel}{target_vessel}", ""))
debug_print(f"路径不可达: {solvent_vessel}{target_vessel}")
action_sequence.append(create_action_log(f"路径不可达: {solvent_vessel}{target_vessel}", ""))
raise ValueError(f"从溶剂容器 '{solvent_vessel}' 到目标容器 '{target_vessel}' 没有可用路径")
# 4. 使用pump_protocol转移溶剂
action_sequence.append(action_log("开始溶剂转移操作...", "🚰"))
action_sequence.append(action_log(f"转移: {solvent_vessel}{target_vessel} ({volume}mL)", "🚛"))
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)", "🚛"))
try:
action_sequence.append(action_log("正在生成泵送协议...", "🔄"))
debug_print("🔄 生成泵送协议...")
action_sequence.append(create_action_log("正在生成泵送协议...", "🔄"))
pump_actions = generate_pump_protocol_with_rinsing(
G=G,
from_vessel=solvent_vessel,
@@ -90,34 +256,41 @@ 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(action_log(f"泵送协议完成 ({len(pump_actions)} 个操作)", ""))
debug_print(f"泵送协议已添加: {len(pump_actions)} 个动作")
action_sequence.append(create_action_log(f"泵送协议完成 ({len(pump_actions)} 个操作)", ""))
except Exception as e:
action_sequence.append(action_log(f"泵送协议生成失败: {str(e)}", ""))
debug_print(f"泵送协议生成失败: {str(e)}")
action_sequence.append(create_action_log(f"泵送协议生成失败: {str(e)}", ""))
raise ValueError(f"生成泵协议时出错: {str(e)}")
# 5. 等待溶剂稳定
action_sequence.append(action_log("等待溶剂稳定...", ""))
original_wait_time = 10.0
simulation_time_limit = 5.0
debug_print("🔍 步骤5: 等待溶剂稳定...")
action_sequence.append(create_action_log("等待溶剂稳定...", ""))
# 模拟运行时间优化
debug_print("⏱️ 检查模拟运行时间限制...")
original_wait_time = 10.0 # 原始等待时间
simulation_time_limit = 5.0 # 模拟运行时间限制5秒
final_wait_time = min(original_wait_time, simulation_time_limit)
if original_wait_time > simulation_time_limit:
action_sequence.append(action_log(f"时间优化: {original_wait_time}s → {final_wait_time}s", ""))
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", ""))
else:
action_sequence.append(action_log(f"等待时间: {final_wait_time}s", ""))
debug_print(f"✅ 时间在限制内: {final_wait_time}s 保持不变")
action_sequence.append(create_action_log(f"等待时间: {final_wait_time}s", ""))
action_sequence.append({
"action_name": "wait",
"action_kwargs": {
@@ -125,50 +298,90 @@ 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:
action_sequence.append(action_log("应用模拟时间优化", "🎭"))
debug_print(f"🎭 模拟优化说明: 原计划 {original_wait_time}s实际模拟 {final_wait_time}s")
action_sequence.append(create_action_log("应用模拟时间优化", "🎭"))
# 总结
debug_print(f"重置处理协议生成完成: {len(action_sequence)} 个动作, {solvent_vessel} -> {target_vessel}, {volume}mL")
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)
# 添加完成日志
summary_msg = f"重置处理完成: {target_vessel} (使用 {volume}mL {solvent})"
if vessel is None:
summary_msg += " [默认容器]"
else:
summary_msg += " [指定容器]"
action_sequence.append(action_log(summary_msg, "🎉"))
action_sequence.append(create_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("测试完成")
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("✅ 测试完成")
if __name__ == "__main__":
test_reset_handling_protocol()
test_reset_handling_protocol()

View File

@@ -2,54 +2,60 @@ from typing import List, Dict, Any, Union
import networkx as nx
import logging
import re
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 .utils.vessel_parser import get_vessel
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)
return (50.0, 50.0) # 默认1:1
ratio_str = ratio_str.strip()
debug_print(f"🔍 解析比例: '{ratio_str}'")
# 支持多种分隔符:: / -
if ':' in ratio_str:
parts = ratio_str.split(':')
@@ -60,82 +66,101 @@ 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
return 0.3 # 默认Rf值
rf_str = rf_str.strip().lower()
debug_print(f"🔍 解析Rf值: '{rf_str}'")
# 处理未知Rf值
if rf_str in ['?', 'unknown', 'tbd', 'to be determined']:
return 0.3
default_rf = 0.3
debug_print(f"❓ 检测到未知Rf值使用默认值: {default_rf}")
return default_rf
# 提取数字
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))
value = value / 100.0 # 可能是百分比形式
value = max(0.0, min(1.0, value)) # 限制在0-1范围
debug_print(f"✅ Rf值解析结果: {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",
@@ -144,25 +169,211 @@ 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 = "",
@@ -174,7 +385,7 @@ def generate_run_column_protocol(
) -> List[Dict[str, Any]]:
"""
生成柱层析分离的协议序列 - 支持vessel字典和体积运算
Args:
G: 有向图,节点为设备和容器,边为流体管道
from_vessel: 源容器字典从XDL传入
@@ -187,112 +398,173 @@ 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(f"开始生成柱层析协议: {from_vessel_id} -> {to_vessel_id}, column={column}")
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)
action_sequence = []
# 记录柱层析前的容器状态
original_from_volume = get_resource_liquid_volume(from_vessel)
original_to_volume = get_resource_liquid_volume(to_vessel)
# 🔧 新增:记录柱层析前的容器状态
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")
# === 参数验证 ===
if not from_vessel_id:
debug_print("📍 步骤1: 参数验证...")
if not from_vessel_id: # 🔧 使用 from_vessel_id
raise ValueError("from_vessel 参数不能为空")
if not to_vessel_id:
if not to_vessel_id: # 🔧 使用 to_vessel_id
raise ValueError("to_vessel 参数不能为空")
if not column:
raise ValueError("column 参数不能为空")
if from_vessel_id not in G.nodes():
if from_vessel_id not in G.nodes(): # 🔧 使用 from_vessel_id
raise ValueError(f"源容器 '{from_vessel_id}' 不存在于系统中")
if to_vessel_id not in G.nodes():
if to_vessel_id not in G.nodes(): # 🔧 使用 to_vessel_id
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"参数: rf={final_rf}, 溶剂={final_solvent1}:{final_solvent2} = {final_pct1:.1f}%:{final_pct2:.1f}%")
debug_print(f"🧪 最终溶剂: {final_solvent1} : {final_solvent2}")
# === 查找设备和容器 ===
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
source_volume = 50.0 # 默认体积
debug_print(f"⚠️ 无法获取源容器体积,使用默认值: {source_volume}mL")
else:
debug_print(f"✅ 源容器体积: {source_volume}mL")
# === 计算溶剂体积 ===
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:
# 步骤1: 样品上柱
if column_vessel and column_vessel != from_vessel_id:
# 步骤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}")
try:
sample_transfer_actions = generate_pump_protocol_with_rinsing(
G=G,
from_vessel=from_vessel_id,
from_vessel=from_vessel_id, # 🔧 使用 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)
current_from_volume = 0.0
current_column_volume = source_volume
debug_print(f"✅ 样品上柱完成,添加了 {len(sample_transfer_actions)} 个动作")
# 🔧 新增:更新体积 - 样品转移到柱上
current_from_volume = 0.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)}")
# 步骤2: 添加洗脱溶剂1
debug_print(f"⚠️ 样品上柱失败: {str(e)}")
# 步骤6.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
target_vessel = column_vessel if column_vessel else from_vessel_id # 🔧 使用 from_vessel_id
solvent1_transfer_actions = generate_pump_protocol_with_rinsing(
G=G,
from_vessel=solvent1_vessel,
@@ -302,22 +574,27 @@ 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)}")
# 步骤3: 添加洗脱溶剂2
debug_print(f"⚠️ 溶剂1添加失败: {str(e)}")
# 步骤6.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
target_vessel = column_vessel if column_vessel else from_vessel_id # 🔧 使用 from_vessel_id
solvent2_transfer_actions = generate_pump_protocol_with_rinsing(
G=G,
from_vessel=solvent2_vessel,
@@ -327,26 +604,31 @@ 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)}")
# 步骤4: 使用柱层析设备执行分离
debug_print(f"⚠️ 溶剂2添加失败: {str(e)}")
# 步骤6.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,
"to_vessel": to_vessel_id,
"from_vessel": from_vessel_id, # 🔧 使用 from_vessel_id
"to_vessel": to_vessel_id, # 🔧 使用 to_vessel_id
"column": column,
"rf": rf,
"pct1": pct1,
@@ -357,65 +639,85 @@ def generate_run_column_protocol(
}
}
action_sequence.append(column_separation_action)
separation_time = max(30, min(120, int(total_elution_volume / 2)))
debug_print(f"✅ 柱层析设备动作已添加")
# 等待分离完成
separation_time = max(30, min(120, int(total_elution_volume / 2))) # 30-120秒基于体积
action_sequence.append({
"action_name": "wait",
"action_kwargs": {"time": separation_time}
})
# 步骤5: 产物收集
if column_vessel and column_vessel != to_vessel_id:
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}")
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=to_vessel_id, # 🔧 使用 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: 简化模式 - 直接转移
debug_print(f"⚠️ 产物收集失败: {str(e)}")
# 步骤6.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,
to_vessel=to_vessel_id,
from_vessel=from_vessel_id, # 🔧 使用 from_vessel_id
to_vessel=to_vessel_id, # 🔧 使用 to_vessel_id
volume=source_volume,
flowrate=2.0,
transfer_flowrate=1.0
)
action_sequence.extend(direct_transfer_actions)
current_from_volume = 0.0
current_to_volume += source_volume
debug_print(f"✅ 直接转移完成,添加了 {len(direct_transfer_actions)} 个动作")
# 🔧 新增:更新体积 - 直接转移
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)}")
debug_print(f"协议生成失败: {str(e)} 😭")
# 不添加不确定的动作直接让action_sequence保持为空列表
# action_sequence 已经在函数开始时初始化为 []
# 确保至少有一个有效的动作,如果完全失败就返回空列表
if not action_sequence:
debug_print("⚠️ 没有生成任何有效动作")
# 可以选择返回空列表或添加一个基本的等待动作
action_sequence.append({
"action_name": "wait",
"action_kwargs": {
@@ -423,50 +725,83 @@ def generate_run_column_protocol(
"description": "柱层析协议执行完成"
}
})
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")
# 🔧 新增:柱层析完成后的最终状态报告
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)
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]]:
"""乙酸乙酯-己烷柱层析(常用组合)"""
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
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,
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]]:
"""甲醇-二氯甲烷柱层析"""
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
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,
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]]:
"""极性化合物柱层析(高极性溶剂比例)"""
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
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,
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]]:
"""非极性化合物柱层析(低极性溶剂比例)"""
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
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,
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()

View File

@@ -1,11 +1,41 @@
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, 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 .utils.vessel_parser import get_vessel
from .utils.logger_util import action_log
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,
@@ -63,33 +93,45 @@ def generate_separate_protocol(
# 🔧 核心修改从字典中提取容器ID
vessel_id, vessel_data = get_vessel(vessel)
debug_print(f"开始生成分离协议: vessel={vessel_id}, purpose={purpose}, "
f"product_phase={product_phase}, solvent={solvent}, "
f"volume={volume}, repeats={repeats}")
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)
action_sequence = []
# 记录分离前的容器状态
original_liquid_volume = get_resource_liquid_volume(vessel)
debug_print(f"分离前液体体积: {original_liquid_volume:.2f}mL")
# 🔧 新增:记录分离前的容器状态
debug_print("🔍 记录分离前容器状态...")
original_liquid_volume = get_vessel_liquid_volume(vessel)
debug_print(f"📊 分离前液体体积: {original_liquid_volume:.2f}mL")
# === 参数验证和标准化 ===
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]"))
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}", "📊"))
# 统一容器参数 - 支持字典和字符串
final_vessel_id = vessel_id
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 ""
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_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)
# 统一体积参数
final_volume = parse_volume_input(volume or solvent_volume)
@@ -99,12 +141,16 @@ def generate_separate_protocol(
repeats = 1
debug_print(f"⚠️ 重复次数参数 <= 0自动设置为 1")
debug_print(f"标准化参数: vessel={final_vessel_id}, to={final_to_vessel_id}, "
f"waste={final_waste_vessel_id}, volume={final_volume}mL, repeats={repeats}")
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}")
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]"))
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}", "🔄"))
# 验证必需参数
if not purpose:
@@ -114,68 +160,72 @@ def generate_separate_protocol(
if purpose not in ["wash", "extract", "separate"]:
debug_print(f"⚠️ 未知的分离目的 '{purpose}',使用默认值 'separate'")
purpose = "separate"
action_sequence.append(action_log(f"未知目的,使用: {purpose}", "⚠️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"未知目的,使用: {purpose}", "⚠️"))
if product_phase not in ["top", "bottom"]:
debug_print(f"⚠️ 未知的产物相 '{product_phase}',使用默认值 'top'")
product_phase = "top"
action_sequence.append(action_log(f"未知相别,使用: {product_phase}", "⚠️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"未知相别,使用: {product_phase}", "⚠️"))
action_sequence.append(action_log("参数验证通过", "", 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("正在查找相关设备...", "🔍"))
# 查找分离器设备
separator_device = find_separator_device(G, final_vessel_id)
separator_device = find_separator_device(G, final_vessel_id) # 🔧 使用 final_vessel_id
if separator_device:
action_sequence.append(action_log(f"找到分离器设备: {separator_device}", "🧪", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"找到分离器设备: {separator_device}", "🧪"))
else:
debug_print("⚠️ 未找到分离器设备,可能无法执行分离")
action_sequence.append(action_log("未找到分离器设备", "⚠️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log("未找到分离器设备", "⚠️"))
# 查找搅拌器
stirrer_device = find_connected_stirrer(G, final_vessel_id)
stirrer_device = find_connected_stirrer(G, final_vessel_id) # 🔧 使用 final_vessel_id
if stirrer_device:
action_sequence.append(action_log(f"找到搅拌器: {stirrer_device}", "🌪️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"找到搅拌器: {stirrer_device}", "🌪️"))
else:
action_sequence.append(action_log("未找到搅拌器", "⚠️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log("未找到搅拌器", "⚠️"))
# 查找溶剂容器(如果需要)
solvent_vessel = ""
if solvent and solvent.strip():
try:
solvent_vessel = find_solvent_vessel(G, solvent)
except ValueError:
solvent_vessel = ""
solvent_vessel = find_solvent_vessel(G, solvent)
if solvent_vessel:
action_sequence.append(action_log(f"找到溶剂容器: {solvent_vessel}", "💧", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"找到溶剂容器: {solvent_vessel}", "💧"))
else:
action_sequence.append(action_log(f"未找到溶剂容器: {solvent}", "⚠️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"未找到溶剂容器: {solvent}", "⚠️"))
debug_print(f"设备配置: separator={separator_device}, stirrer={stirrer_device}, solvent_vessel={solvent_vessel}")
debug_print(f"📊 设备配置:")
debug_print(f" 🧪 分离器设备: '{separator_device}'")
debug_print(f" 🌪️ 搅拌器设备: '{stirrer_device}'")
debug_print(f" 💧 溶剂容器: '{solvent_vessel}'")
# === 执行分离流程 ===
action_sequence.append(action_log("开始分离工作流程", "🎯", prefix="[SEPARATE]"))
debug_print("🔍 步骤3: 执行分离流程...")
action_sequence.append(create_action_log("开始分离工作流程", "🎯"))
# 体积变化跟踪变量
# 🔧 新增:体积变化跟踪变量
current_volume = original_liquid_volume
try:
for repeat_idx in range(repeats):
cycle_num = repeat_idx + 1
debug_print(f"分离循环 {cycle_num}/{repeats} 开始")
action_sequence.append(action_log(f"分离循环 {cycle_num}/{repeats} 开始", "🔄", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮: 开始分离循环 {cycle_num}/{repeats}")
action_sequence.append(create_action_log(f"分离循环 {cycle_num}/{repeats} 开始", "🔄"))
# 步骤3.1: 添加溶剂(如果需要)
if solvent_vessel and final_volume > 0:
action_sequence.append(action_log(f"向分离容器添加 {final_volume}mL {solvent}", "💧", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮 步骤1: 添加溶剂 {solvent} ({final_volume}mL)")
action_sequence.append(create_action_log(f"向分离容器添加 {final_volume}mL {solvent}", "💧"))
try:
# 使用pump protocol添加溶剂
pump_actions = generate_pump_protocol_with_rinsing(
G=G,
from_vessel=solvent_vessel,
to_vessel=final_vessel_id,
to_vessel=final_vessel_id, # 🔧 使用 final_vessel_id
volume=final_volume,
amount="",
time=0.0,
@@ -192,27 +242,30 @@ def generate_separate_protocol(
**kwargs
)
action_sequence.extend(pump_actions)
action_sequence.append(action_log(f"溶剂转移完成 ({len(pump_actions)} 个操作)", "", prefix="[SEPARATE]"))
debug_print(f"✅ 溶剂添加完成,添加了 {len(pump_actions)} 个动作")
action_sequence.append(create_action_log(f"溶剂转移完成 ({len(pump_actions)} 个操作)", ""))
# 更新体积 - 添加溶剂后
# 🔧 新增:更新体积 - 添加溶剂后
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(action_log(f"溶剂添加失败: {str(e)}", "", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"溶剂添加失败: {str(e)}", ""))
else:
action_sequence.append(action_log("无需添加溶剂", "⏭️", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮 步骤1: 无需添加溶剂")
action_sequence.append(create_action_log("无需添加溶剂", "⏭️"))
# 步骤3.2: 启动搅拌(如果有搅拌器)
if stirrer_device and stir_time > 0:
action_sequence.append(action_log(f"开始搅拌: {stir_speed}rpm持续 {stir_time}s", "🌪️", prefix="[SEPARATE]"))
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({
"device_id": stirrer_device,
"action_name": "start_stir",
"action_kwargs": {
"vessel": {"id": final_vessel_id},
"vessel": {"id": final_vessel_id}, # 🔧 使用 final_vessel_id
"stir_speed": stir_speed,
"purpose": f"分离混合 - {purpose}"
}
@@ -220,37 +273,43 @@ def generate_separate_protocol(
# 搅拌等待
stir_minutes = stir_time / 60
action_sequence.append(action_log(f"搅拌中,持续 {stir_minutes:.1f} 分钟", "⏱️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"搅拌中,持续 {stir_minutes:.1f} 分钟", "⏱️"))
action_sequence.append({
"action_name": "wait",
"action_kwargs": {"time": stir_time}
})
# 停止搅拌
action_sequence.append(action_log("停止搅拌器", "🛑", prefix="[SEPARATE]"))
action_sequence.append(create_action_log("停止搅拌器", "🛑"))
action_sequence.append({
"device_id": stirrer_device,
"action_name": "stop_stir",
"action_kwargs": {"vessel": final_vessel_id}
"action_kwargs": {"vessel": final_vessel_id} # 🔧 使用 final_vessel_id
})
else:
action_sequence.append(action_log("无需搅拌", "⏭️", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮 步骤2: 无需搅拌")
action_sequence.append(create_action_log("无需搅拌", "⏭️"))
# 步骤3.3: 静置分层
if settling_time > 0:
debug_print(f"🔄 第{cycle_num}轮 步骤3: 静置分层 ({settling_time}s)")
settling_minutes = settling_time / 60
action_sequence.append(action_log(f"静置分层 ({settling_minutes:.1f} 分钟)", "⚖️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"静置分层 ({settling_minutes:.1f} 分钟)", "⚖️"))
action_sequence.append({
"action_name": "wait",
"action_kwargs": {"time": settling_time}
})
else:
action_sequence.append(action_log("未指定静置时间", "⏭️", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮 步骤3: 未指定静置时间")
action_sequence.append(create_action_log("未指定静置时间", "⏭️"))
# 步骤3.4: 执行分离操作
if separator_device:
action_sequence.append(action_log(f"执行分离: 收集{product_phase}", "🧪", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮 步骤4: 执行分离操作")
action_sequence.append(create_action_log(f"执行分离: 收集{product_phase}", "🧪"))
# 🔧 替换为具体的分离操作逻辑基于old版本
# 首先进行分液判断(电导突跃)
action_sequence.append({
@@ -265,10 +324,11 @@ 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":
action_sequence.append(action_log("收集底相产物", "📦", prefix="[SEPARATE]"))
debug_print(f"🔄 收集底相产物{final_to_vessel_id}")
action_sequence.append(create_action_log("收集底相产物", "📦"))
# 产物转移到目标瓶
if final_to_vessel_id:
@@ -304,7 +364,8 @@ def generate_separate_protocol(
action_sequence.extend(pump_actions)
elif product_phase == "top":
action_sequence.append(action_log("收集上相产物", "📦", prefix="[SEPARATE]"))
debug_print(f"🔄 收集上相产物{final_to_vessel_id}")
action_sequence.append(create_action_log("收集上相产物", "📦"))
# 弃去下面那一相进废液
if final_waste_vessel_id:
@@ -339,9 +400,10 @@ def generate_separate_protocol(
)
action_sequence.extend(pump_actions)
action_sequence.append(action_log("分离操作完成", "", prefix="[SEPARATE]"))
debug_print(f"分离操作完成")
action_sequence.append(create_action_log("分离操作完成", ""))
# 分离后体积估算
# 🔧 新增:分离后体积估算
separated_volume = phase_volume * 0.95 # 假设5%损失,只保留产物相体积
update_vessel_volume(vessel, G, separated_volume, f"分离操作后(第{cycle_num}轮)")
current_volume = separated_volume
@@ -349,21 +411,23 @@ def generate_separate_protocol(
# 收集结果
if final_to_vessel_id:
action_sequence.append(
action_log(f"产物 ({product_phase}相) 收集到: {final_to_vessel_id}", "📦", prefix="[SEPARATE]"))
create_action_log(f"产物 ({product_phase}相) 收集到: {final_to_vessel_id}", "📦"))
if final_waste_vessel_id:
action_sequence.append(action_log(f"废相收集到: {final_waste_vessel_id}", "🗑️", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"废相收集到: {final_waste_vessel_id}", "🗑️"))
else:
action_sequence.append(action_log("无分离器设备可用", "", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮 步骤4: 无分离器设备,跳过分离")
action_sequence.append(create_action_log("无分离器设备可用", ""))
# 添加等待时间模拟分离
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:
action_sequence.append(action_log("产物转回分离容器准备下一轮", "🔄", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮: 产物转回分离容器准备下一轮")
action_sequence.append(create_action_log("产物转回分离容器,准备下一轮", "🔄"))
pump_actions = generate_pump_protocol_with_rinsing(
G=G,
@@ -380,85 +444,368 @@ def generate_separate_protocol(
# 循环间等待(除了最后一次)
if repeat_idx < repeats - 1:
action_sequence.append(action_log("等待下一次循环...", "", prefix="[SEPARATE]"))
debug_print(f"🔄 第{cycle_num}轮: 等待下一次循环...")
action_sequence.append(create_action_log("等待下一次循环...", ""))
action_sequence.append({
"action_name": "wait",
"action_kwargs": {"time": 5}
})
else:
action_sequence.append(action_log(f"分离循环 {cycle_num}/{repeats} 完成", "🌟", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"分离循环 {cycle_num}/{repeats} 完成", "🌟"))
except Exception as e:
debug_print(f"❌ 分离工作流程执行失败: {str(e)}")
action_sequence.append(action_log(f"分离工作流程失败: {str(e)}", "", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(f"分离工作流程失败: {str(e)}", ""))
# 分离完成后的最终状态报告
final_liquid_volume = get_resource_liquid_volume(vessel)
# 🔧 新增:分离完成后的最终状态报告
final_liquid_volume = get_vessel_liquid_volume(vessel)
# === 最终结果 ===
total_time = (stir_time + settling_time + 15) * repeats # 估算总时间
debug_print(f"分离协议生成完成: {len(action_sequence)} 个动作, "
f"预计 {total_time:.0f}s, 体积 {original_liquid_volume:.2f}{final_liquid_volume:.2f}mL")
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)
# 添加完成日志
summary_msg = f"分离协议完成: {final_vessel_id} ({purpose}{repeats} 次循环)"
if solvent:
summary_msg += f",使用 {final_volume * repeats:.2f}mL {solvent}"
action_sequence.append(action_log(summary_msg, "🎉", prefix="[SEPARATE]"))
action_sequence.append(create_action_log(summary_msg, "🎉"))
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使用第一个可用分离器
# 方法3查找第一个分离器设备
debug_print(f"📋 方法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",
@@ -467,25 +814,32 @@ 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查找与分离器相连的容器
# 方法2查找与分离器相连的容器(假设底部容器会与分离器相连)
debug_print(f"📋 方法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 ""

View File

@@ -1,40 +1,116 @@
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) -> str:
"""从vessel参数中提取vessel_id兼容 str / dict / ResourceDictInstance"""
return get_resource_id(vessel)
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 get_vessel_display_info(vessel) -> str:
"""获取容器的显示信息(用于日志),兼容 str / dict / ResourceDictInstance"""
return get_resource_display_info(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 generate_stir_protocol(
G: nx.DiGraph,
@@ -49,13 +125,16 @@ 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
vessel_resource = vessel # 已经是完整的Resource字典
debug_print(f"✅ 使用传入的vessel Resource对象")
else:
# 如果只是字符串构建一个基本的Resource对象
vessel_resource = {
"id": vessel,
"name": "",
@@ -71,60 +150,91 @@ def generate_stir_protocol(
"sample_id": "",
"type": ""
}
# 参数验证
if not vessel_id:
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 参数不能为空! 😱")
raise ValueError("vessel 参数不能为空")
if vessel_id not in G.nodes():
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
debug_print(f"❌ 容器 '{vessel_id}' 不存在于系统中! 😞")
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
# 参数解析 — 确定实际时间优先级time_spec > stir_time > time
debug_print("✅ 基础参数验证通过 🎯")
# 🔄 参数解析
debug_print("📍 步骤2: 参数解析... ⚡")
# 确定实际时间优先级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(f"🎯 最终参数: time={parsed_time}s, speed={stir_speed}RPM, settling={parsed_settling_time}s")
# 🔍 查找设备
debug_print("📍 步骤3: 查找搅拌设备... 🔍")
try:
stirrer_id = find_connected_stirrer(G, vessel_id)
stirrer_id = find_connected_stirrer(G, vessel_id) # 🔧 使用 vessel_id
debug_print(f"🎉 使用搅拌设备: {stirrer_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": vessel_id},
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
"time": str(time),
"event": event,
"time_spec": time_spec,
@@ -134,14 +244,22 @@ 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"模拟优化: 搅拌 {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")
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)
return action_sequence
@@ -154,13 +272,16 @@ 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
vessel_resource = vessel # 已经是完整的Resource字典
debug_print(f"✅ 使用传入的vessel Resource对象")
else:
# 如果只是字符串构建一个基本的Resource对象
vessel_resource = {
"id": vessel,
"name": "",
@@ -176,29 +297,39 @@ 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": vessel_id},
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
"stir_speed": stir_speed,
"purpose": purpose or f"启动搅拌 {stir_speed} RPM"
}
}]
debug_print(f"启动搅拌协议: {vessel_display}, {stir_speed}RPM, device={stirrer_id}")
debug_print(f"启动搅拌协议生成完成 🎯")
return action_sequence
def generate_stop_stir_protocol(
@@ -208,13 +339,16 @@ 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
vessel_resource = vessel # 已经是完整的Resource字典
debug_print(f"✅ 使用传入的vessel Resource对象")
else:
# 如果只是字符串构建一个基本的Resource对象
vessel_resource = {
"id": vessel,
"name": "",
@@ -230,103 +364,115 @@ 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": vessel_id},
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
}
}]
debug_print(f"停止搅拌协议: {vessel_display}, device={stirrer_id}")
debug_print(f"停止搅拌协议生成完成 🎯")
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()

View File

@@ -1,57 +1,36 @@
"""编译器共享日志工具"""
import inspect
# 🆕 创建进度日志动作
import logging
from typing import Dict, Any
# 模块名到前缀的映射
_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]",
}
logger = logging.getLogger(__name__)
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")
def debug_print(message, prefix="[UNIT_PARSER]"):
"""调试输出"""
logger.info(f"{prefix} {message}")
def action_log(message: str, emoji: str = "📝", prefix="[HIGH-LEVEL OPERATION]") -> Dict[str, Any]:
"""创建一个动作日志"""
full_message = f"{prefix} {emoji} {message}"
return {
"action_name": "wait",
"action_kwargs": {
"time": 0.1,
"log_message": full_message,
"progress_message": full_message
"""创建一个动作日志 - 支持中文和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
}
}
}
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
}
}

View File

@@ -1,172 +0,0 @@
"""
资源实例兼容层
提供 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")

View File

@@ -184,42 +184,6 @@ 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():
"""测试单位解析功能"""

View File

@@ -1,23 +1,27 @@
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: 可以是一个字典字符串或 ResourceDictInstance表示vessel的ID或数据。
vessel: 可以是一个字典字符串表示vessel的ID或数据。
Returns:
tuple: 包含vessel_id和vessel_data。
"""
# 统一使用 resource_helper 处理
vessel_id = get_resource_id(vessel)
vessel_data = get_resource_data(vessel)
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 = {}
return vessel_id, vessel_data
@@ -274,31 +278,4 @@ def find_solid_dispenser(G: nx.DiGraph) -> str:
return node
debug_print(f"❌ 未找到固体加样器")
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"
return ""

View File

@@ -4,55 +4,199 @@ 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) -> str:
"""从vessel参数中提取vessel_id兼容 str / dict / ResourceDictInstance"""
return get_resource_id(vessel)
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 get_vessel_display_info(vessel) -> str:
"""获取容器的显示信息(用于日志),兼容 str / dict / ResourceDictInstance"""
return get_resource_display_info(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 generate_wash_solid_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
vessel: Union[str, dict], # 🔧 修改支持vessel字典
solvent: str,
volume: Union[float, str] = "50",
filtrate_vessel: Union[str, dict] = "",
filtrate_vessel: Union[str, dict] = "", # 🔧 修改支持vessel字典
temp: float = 25.0,
stir: bool = False,
stir_speed: float = 0.0,
@@ -66,7 +210,7 @@ def generate_wash_solid_protocol(
) -> List[Dict[str, Any]]:
"""
生成固体清洗协议 - 支持vessel字典和体积运算
Args:
G: 有向图,节点为设备和容器,边为流体管道
vessel: 清洗容器字典从XDL传入或容器ID字符串
@@ -83,78 +227,106 @@ 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(f"开始生成固体清洗协议: vessel={vessel_id}, solvent={solvent}, volume={volume}, repeats={repeats}")
# 记录清洗前的容器状态
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("🔍 记录清洗前容器状态...")
if isinstance(vessel, dict):
original_volume = get_resource_liquid_volume(vessel)
original_volume = get_vessel_liquid_volume(vessel)
debug_print(f"📊 清洗前液体体积: {original_volume:.2f}mL")
else:
original_volume = 0.0
# 快速验证
if not vessel_id or vessel_id not in G.nodes():
debug_print(f"📊 vessel为字符串格式无法获取体积信息")
# 📋 快速验证
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
debug_print("❌ 容器验证失败! 😱")
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))
# 模拟时间优化
final_repeats = max(1, min(repeats, 5)) # 限制1-5次
# 🕐 模拟时间优化
debug_print(" ⏱️ 模拟时间优化...")
original_time = final_time
if final_time > 60.0:
final_time = 60.0
debug_print(f"时间优化: {original_time}s -> {final_time}s")
final_time = 60.0 # 限制最长60秒
debug_print(f" 🎮 时间优化: {original_time}s {final_time}s")
# 参数修正
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}")
# 查找设备
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: 查找设备... 🔍")
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,
to_vessel=vessel_id, # 🔧 使用 vessel_id
volume=final_volume,
amount="",
time=0.0,
@@ -166,160 +338,211 @@ 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)
# 更新体积 - 过滤后
filtered_volume = current_volume * 0.9
debug_print(f" ✅ 过滤动作: → {actual_filtrate_vessel} 🌊")
# 🔧 新增:更新体积 - 过滤后(液体被滤除)
# 假设滤液完全被移除,固体残留在容器中
filtered_volume = current_volume * 0.9 # 假设90%的液体被过滤掉
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
# 4. 等待(缩短时间)
wait_time = 5.0 # 🕐 缩短等待时间10s → 5s
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_resource_liquid_volume(vessel)
final_volume_vessel = get_vessel_liquid_volume(vessel)
else:
final_volume_vessel = current_volume
debug_print(f"固体清洗协议生成完成: {len(action_sequence)} 个动作, {final_repeats}次清洗, 溶剂总用量={total_solvent_used:.2f}mL")
# 🎊 总结
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)
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]]:
"""用冷溶剂清洗固体"""
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
vessel_display = get_vessel_display_info(vessel)
debug_print(f"❄️ 冷{solvent}洗固体: {vessel_display} ({repeats} 次)")
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]]:
"""用热溶剂清洗固体"""
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
vessel_display = get_vessel_display_info(vessel)
debug_print(f"🔥 热{solvent}洗固体: {vessel_display} ({repeats} 次)")
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]]:
"""带搅拌的溶剂清洗"""
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
stir=True, stir_speed=200.0,
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,
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_dict = {"id": "filter_flask_1", "name": "过滤瓶1",
debug_print("🧪 === WASH SOLID PROTOCOL 测试 ===")
# 测试vessel参数处理
debug_print("🔧 测试vessel参数处理...")
# 测试字典格式
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_resource_liquid_volume(vessel_dict)
debug_print(f"字典格式: ID={vessel_id}, 显示={vessel_display}, 体积={volume}mL")
volume = get_vessel_liquid_volume(vessel_dict)
debug_print(f" 字典格式: {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"字符串格式: ID={vessel_id}, 显示={vessel_display}")
debug_print("测试完成")
debug_print(f" 字符串格式: {vessel_str}")
debug_print(f" → ID: {vessel_id}, 显示: {vessel_display}")
debug_print("✅ 测试完成 🎉")
if __name__ == "__main__":
test_wash_solid_protocol()
test_wash_solid_protocol()

View File

@@ -46,7 +46,7 @@ class WSConfig:
# HTTP配置
class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1"
remote_addr = "https://leap-lab.bohrium.com/api/v1"
# ROS配置

View File

@@ -1,9 +1,7 @@
"""
LaiYu液体处理设备后端模块
提供设备后端接口和实现
"""
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
__all__ = ['UniLiquidHandlerLaiyuBackend']

View File

@@ -1,334 +0,0 @@
"""
LaiYu液体处理设备后端实现
提供设备的后端接口和控制逻辑
"""
import logging
from typing import Dict, Any, Optional, List
from abc import ABC, abstractmethod
# 尝试导入PyLabRobot后端
try:
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
PYLABROBOT_AVAILABLE = True
except ImportError:
PYLABROBOT_AVAILABLE = False
# 创建模拟后端基类
class LiquidHandlerBackend:
def __init__(self, name: str):
self.name = name
self.is_connected = False
def connect(self):
"""连接设备"""
pass
def disconnect(self):
"""断开连接"""
pass
class LaiYuLiquidBackend(LiquidHandlerBackend):
"""LaiYu液体处理设备后端"""
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
"""
初始化LaiYu液体处理设备后端
Args:
name: 后端名称
"""
if PYLABROBOT_AVAILABLE:
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
super().__init__()
else:
# 模拟版本接受 name 参数
super().__init__(name)
self.name = name
self.logger = logging.getLogger(__name__)
self.is_connected = False
self.device_info = {
"name": "LaiYu液体处理设备",
"version": "1.0.0",
"manufacturer": "LaiYu",
"model": "LaiYu_Liquid_Handler"
}
def connect(self) -> bool:
"""
连接到LaiYu液体处理设备
Returns:
bool: 连接是否成功
"""
try:
self.logger.info("正在连接到LaiYu液体处理设备...")
# 这里应该实现实际的设备连接逻辑
# 目前返回模拟连接成功
self.is_connected = True
self.logger.info("成功连接到LaiYu液体处理设备")
return True
except Exception as e:
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
self.is_connected = False
return False
def disconnect(self) -> bool:
"""
断开与LaiYu液体处理设备的连接
Returns:
bool: 断开连接是否成功
"""
try:
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
# 这里应该实现实际的设备断开连接逻辑
self.is_connected = False
self.logger.info("成功断开与LaiYu液体处理设备的连接")
return True
except Exception as e:
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
return False
def is_device_connected(self) -> bool:
"""
检查设备是否已连接
Returns:
bool: 设备是否已连接
"""
return self.is_connected
def get_device_info(self) -> Dict[str, Any]:
"""
获取设备信息
Returns:
Dict[str, Any]: 设备信息字典
"""
return self.device_info.copy()
def home_device(self) -> bool:
"""
设备归零操作
Returns:
bool: 归零是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行归零操作")
return False
try:
self.logger.info("正在执行设备归零操作...")
# 这里应该实现实际的设备归零逻辑
self.logger.info("设备归零操作完成")
return True
except Exception as e:
self.logger.error(f"设备归零操作失败: {e}")
return False
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
"""
吸液操作
Args:
volume: 吸液体积 (微升)
location: 吸液位置信息
Returns:
bool: 吸液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行吸液操作")
return False
try:
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的吸液逻辑
self.logger.info("吸液操作完成")
return True
except Exception as e:
self.logger.error(f"吸液操作失败: {e}")
return False
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
"""
排液操作
Args:
volume: 排液体积 (微升)
location: 排液位置信息
Returns:
bool: 排液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行排液操作")
return False
try:
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的排液逻辑
self.logger.info("排液操作完成")
return True
except Exception as e:
self.logger.error(f"排液操作失败: {e}")
return False
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
"""
取枪头操作
Args:
location: 枪头位置信息
Returns:
bool: 取枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行取枪头操作")
return False
try:
self.logger.info(f"正在执行取枪头操作: 位置={location}")
# 这里应该实现实际的取枪头逻辑
self.logger.info("取枪头操作完成")
return True
except Exception as e:
self.logger.error(f"取枪头操作失败: {e}")
return False
def drop_tip(self, location: Dict[str, Any]) -> bool:
"""
丢弃枪头操作
Args:
location: 丢弃位置信息
Returns:
bool: 丢弃枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行丢弃枪头操作")
return False
try:
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
# 这里应该实现实际的丢弃枪头逻辑
self.logger.info("丢弃枪头操作完成")
return True
except Exception as e:
self.logger.error(f"丢弃枪头操作失败: {e}")
return False
def move_to(self, location: Dict[str, Any]) -> bool:
"""
移动到指定位置
Args:
location: 目标位置信息
Returns:
bool: 移动是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行移动操作")
return False
try:
self.logger.info(f"正在移动到位置: {location}")
# 这里应该实现实际的移动逻辑
self.logger.info("移动操作完成")
return True
except Exception as e:
self.logger.error(f"移动操作失败: {e}")
return False
def get_status(self) -> Dict[str, Any]:
"""
获取设备状态
Returns:
Dict[str, Any]: 设备状态信息
"""
return {
"connected": self.is_connected,
"device_info": self.device_info,
"status": "ready" if self.is_connected else "disconnected"
}
# PyLabRobot 抽象方法实现
def stop(self):
"""停止所有操作"""
self.logger.info("停止所有操作")
pass
@property
def num_channels(self) -> int:
"""返回通道数量"""
return 1 # 单通道移液器
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
"""检查是否可以拾取吸头"""
return True # 简化实现总是返回True
def pick_up_tips(self, tip_rack, tip_positions):
"""拾取多个吸头"""
self.logger.info(f"拾取吸头: {tip_positions}")
pass
def drop_tips(self, tip_rack, tip_positions):
"""丢弃多个吸头"""
self.logger.info(f"丢弃吸头: {tip_positions}")
pass
def pick_up_tips96(self, tip_rack):
"""拾取96个吸头"""
self.logger.info("拾取96个吸头")
pass
def drop_tips96(self, tip_rack):
"""丢弃96个吸头"""
self.logger.info("丢弃96个吸头")
pass
def aspirate96(self, volume, plate, well_positions):
"""96通道吸液"""
self.logger.info(f"96通道吸液: 体积={volume}")
pass
def dispense96(self, volume, plate, well_positions):
"""96通道排液"""
self.logger.info(f"96通道排液: 体积={volume}")
pass
def pick_up_resource(self, resource, location):
"""拾取资源"""
self.logger.info(f"拾取资源: {resource}")
pass
def drop_resource(self, resource, location):
"""放置资源"""
self.logger.info(f"放置资源: {resource}")
pass
def move_picked_up_resource(self, resource, location):
"""移动已拾取的资源"""
self.logger.info(f"移动资源: {resource}{location}")
pass
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
"""
创建LaiYu液体处理设备后端实例
Args:
name: 后端名称
Returns:
LaiYuLiquidBackend: 后端实例
"""
return LaiYuLiquidBackend(name)

View File

@@ -1,385 +1,307 @@
import json
"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式
硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致:
1. XYZController(auto_connect=True) — 先开串口
2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁
3. home_all_axes() + pipette.initialize()
"""
import logging
from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import (
LiquidHandlerBackend,
)
from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend
from pylabrobot.liquid_handling.standard import (
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
)
from pylabrobot.resources import Resource, Tip
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
import time
from rclpy.action import ActionClient
from unilabos_msgs.action import SendCmd
import re
from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import (
PipetteController,
TipStatus,
)
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
logger = logging.getLogger(__name__)
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
"""Chatter box backend for device-free testing. Prints out all operations."""
"""LaiYu 硬件后端 — PLR Backend 接口实现"""
_pip_length = 5
_vol_length = 8
_resource_length = 20
_offset_length = 16
_flow_rate_length = 10
_blowout_length = 10
_lld_z_length = 10
_kwargs_length = 15
_tip_type_length = 12
_max_volume_length = 16
_fitting_depth_length = 20
_tip_length_length = 16
# _pickup_method_length = 20
_filter_length = 10
def __init__(
self,
num_channels: int = 1,
tip_length: float = 0,
total_height: float = 310,
port: str = "/dev/ttyUSB0",
baudrate: int = 115200,
pipette_address: int = 4,
):
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
"""Initialize a chatter box backend."""
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
# rclpy.init()
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
self.hardware_interface = PipetteController(port=port)
# 保存配置,延迟到 setup() 再创建硬件对象
self._port = port
self._baudrate = baudrate
self._pipette_address = pipette_address
async def setup(self):
# self.joint_state_publisher = JointStatePublisher()
# self.hardware_interface.xyz_controller.connect_device()
# self.hardware_interface.xyz_controller.home_all_axes()
await super().setup()
self.hardware_interface.connect()
self.hardware_interface.initialize()
self._xyz: Optional[XYZController] = None
self._pipette_ctrl: Optional[PipetteController] = None
self._ros_node = None
print("Setting up the liquid handler.")
# ------------------------------------------------------------------ lifecycle
async def stop(self):
print("Stopping the liquid handler.")
def post_init(self, ros_node):
"""接收 ROS 节点引用(由 Handler.post_init 调用)"""
self._ros_node = ros_node
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
async def setup(self):
"""按路径 B 顺序初始化硬件"""
await super().setup()
def pipette_aspirate(self, volume: float, flow_rate: float):
# 1. XYZ 先开串口
self._xyz = XYZController(
port=self._port,
baudrate=self._baudrate,
auto_connect=True,
)
if not self._xyz.is_connected:
raise RuntimeError("XYZ 控制器连接失败")
self.hardware_interface.pipette.set_max_speed(flow_rate)
res = self.hardware_interface.pipette.aspirate(volume=volume)
if not res:
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
return
# 2. PipetteController 共享 XYZ 串口
self._pipette_ctrl = PipetteController(
port=self._port,
address=self._pipette_address,
)
self._pipette_ctrl.connect_shared(
serial_conn=self._xyz.serial_conn,
serial_lock=self._xyz.serial_lock,
xyz_controller=self._xyz,
)
self.hardware_interface.current_volume += volume
# 3. 回零 + 移液器初始化
self._xyz.home_all_axes()
self._pipette_ctrl.initialize()
def pipette_dispense(self, volume: float, flow_rate: float):
logger.info("LaiYu 后端硬件初始化完成")
self.hardware_interface.pipette.set_max_speed(flow_rate)
res = self.hardware_interface.pipette.dispense(volume=volume)
if not res:
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
return
self.hardware_interface.current_volume -= volume
async def stop(self):
"""正确断开硬件"""
try:
if self._pipette_ctrl:
self._pipette_ctrl.disconnect_shared()
if self._xyz:
self._xyz.disconnect()
logger.info("LaiYu 后端硬件已断开")
except Exception as e:
logger.error(f"停止后端失败: {e}")
@property
def num_channels(self) -> int:
return self._num_channels
# ------------------------------------------------------------------ helpers
async def assigned_resource_callback(self, resource: Resource):
print(f"Resource {resource.name} was assigned to the liquid handler.")
def _plr_to_machine_coords(self, resource, offset):
"""PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)"""
coordinate = resource.get_absolute_location(x="c", y="c")
x = coordinate.x + offset.x
y = coordinate.y + offset.y
z_plr = coordinate.z + offset.z
return x, -y, self.total_height - (z_plr + self.tip_length)
async def unassigned_resource_callback(self, name: str):
print(f"Resource {name} was unassigned from the liquid handler.")
def _pipette_aspirate(self, volume: float, flow_rate: float):
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
res = self._pipette_ctrl.pipette.aspirate(volume=volume)
if not res:
logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}")
return
self._pipette_ctrl.current_volume += volume
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
print("Picking up tips:")
# print(ops.tip)
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
def _pipette_dispense(self, volume: float, flow_rate: float):
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
res = self._pipette_ctrl.pipette.dispense(volume=volume)
if not res:
logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}")
return
self._pipette_ctrl.current_volume -= volume
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
# print(op.resource.get_absolute_location())
self.tip_length = ops[0].tip.total_tip_length
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("已有枪头,无需重复拾取")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
# goback()
# ------------------------------------------------------------------ properties
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
@property
def num_channels(self) -> int:
return self._num_channels
# ------------------------------------------------------------------ resource callbacks
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
print("Dropping tips:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
async def assigned_resource_callback(self, resource: Resource):
logger.info(f"Resource {resource.name} was assigned to the liquid handler.")
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
async def unassigned_resource_callback(self, name: str):
logger.info(f"Resource {name} was unassigned from the liquid handler.")
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
# print(x, y, z)
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
print("无枪头,无需丢弃")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.hardware_interface.eject_tip
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
# ------------------------------------------------------------------ pick_up_tips
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
print("Aspirating:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
tip = ops[0].tip
self.tip_length = tip.total_tip_length
x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<15}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED:
logger.warning("已有枪头,无需重复拾取")
return
# 判断枪头是否存在
self.hardware_interface._update_tip_status()
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("无枪头,无法吸液")
return
# 判断吸液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
return
try:
# 1. 移到枪头正上方
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200)
# 2. 下压到套枪头深度fitting_depth 是枪头套入长度)
z_pickup = z_top + tip.fitting_depth
self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100)
# 3. 退回安全高度
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height, speed=100
)
except Exception as e:
logger.error(f"pick_up_tips 移动失败: {e}")
raise
# 移动到吸液位置
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
# ------------------------------------------------------------------ drop_tips
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
z -= 20 # 额外下移补偿
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
if blow_out_air_volume >0:
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status == TipStatus.NO_TIP:
logger.warning("无枪头,无需丢弃")
return
try:
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_ctrl.eject_tip() # 修复: 原来缺少 ()
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
except Exception as e:
logger.error(f"drop_tips 失败: {e}")
raise
# ------------------------------------------------------------------ aspirate
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
# print("Dispensing:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
raise RuntimeError("无枪头,无法吸液")
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
flow_rate = backend_kwargs.get("flow_rate", 500)
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
# 判断枪头是否存在
self.hardware_interface._update_tip_status()
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("无枪头,无法排液")
return
# 判断排液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
return
if (
self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume
> self._pipette_ctrl.max_volume
):
raise RuntimeError(
f"吸液量超过枪头容量: "
f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}"
)
# 移动到排液位置
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
if blow_out_air_volume > 0:
self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
if blow_out_air_volume > 0:
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
# ------------------------------------------------------------------ dispense
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
print(f"Picking up tips from {pickup.resource.name}.")
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
print(f"Dropping tips to {drop.resource.name}.")
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
raise RuntimeError("无枪头,无法排液")
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
if isinstance(aspiration, MultiHeadAspirationPlate):
resource = aspiration.wells[0].parent
else:
resource = aspiration.container
print(f"Aspirating {aspiration.volume} from {resource}.")
flow_rate = backend_kwargs.get("flow_rate", 500)
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
print(f"Dispensing {dispense.volume} to {resource}.")
if (
self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0
):
raise RuntimeError(
f"排液量超过当前体积: "
f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0"
)
async def pick_up_resource(self, pickup: ResourcePickup):
print(f"Picking up resource: {pickup}")
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
async def move_picked_up_resource(self, move: ResourceMove):
print(f"Moving picked up resource: {move}")
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
if blow_out_air_volume > 0:
self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
async def drop_resource(self, drop: ResourceDrop):
print(f"Dropping resource: {drop}")
# ------------------------------------------------------------------ 96-channel stubs
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
logger.info(f"Picking up tips from {pickup.resource.name}.")
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
logger.info(f"Dropping tips to {drop.resource.name}.")
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
if isinstance(aspiration, MultiHeadAspirationPlate):
resource = aspiration.wells[0].parent
else:
resource = aspiration.container
logger.info(f"Aspirating {aspiration.volume} from {resource}.")
async def dispense96(
self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]
):
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
logger.info(f"Dispensing {dispense.volume} to {resource}.")
async def pick_up_resource(self, pickup: ResourcePickup):
logger.info(f"Picking up resource: {pickup}")
async def move_picked_up_resource(self, move: ResourceMove):
logger.info(f"Moving picked up resource: {move}")
async def drop_resource(self, drop: ResourceDrop):
logger.info(f"Dropping resource: {drop}")
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True

View File

@@ -5,21 +5,16 @@
封装SOPA移液器的高级控制功能
"""
# 添加项目根目录到Python路径以解决模块导入问题
import sys
import os
from tkinter import N
_current_file = os.path.abspath(__file__)
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file)))))
if _project_root not in sys.path:
sys.path.insert(0, _project_root)
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
# 无论如何都添加项目根目录到路径
current_file = os.path.abspath(__file__)
# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py
# 向上5级到 .../Uni-Lab-OS
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
# 强制添加项目根目录到sys.path的开头
sys.path.insert(0, project_root)
import time
import logging
from typing import Optional, List, Dict, Tuple
@@ -153,7 +148,7 @@ class PipetteController:
logger.error("移液器连接失败")
return False
logger.info("移液器连接成功")
# 连接XYZ步进电机控制器如果提供了端口
if self.xyz_port != self.pipette_port:
try:
@@ -172,24 +167,62 @@ class PipetteController:
try:
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
self.xyz_controller.serial_conn = self.pipette.serial_port
self.xyz_controller.serial_lock = self.pipette.lock
self.xyz_controller.is_connected = True
logger.info("XYZ控制器与移液器共享串口和互斥锁")
except Exception as e:
logger.info("未配置XYZ步进电机端口跳过运动控制器连接")
logger.warning(f"共享端口 XYZ 控制器创建失败: {e}")
self.xyz_controller = None
self.xyz_connected = False
return True
except Exception as e:
logger.error(f"设备连接失败: {e}")
return False
def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool:
"""使用已连接的串口和XYZ控制器路径 B 模式XYZ 先开串口,移液器共享)
Args:
serial_conn: 已打开的串口连接(来自 XYZController
serial_lock: 串口互斥锁(来自 XYZController
xyz_controller: 已连接的 XYZController 实例
"""
try:
self.pipette.serial_port = serial_conn
self.pipette.lock = serial_lock
self.pipette.is_connected = True
self.xyz_controller = xyz_controller
self.xyz_connected = True
logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口")
return True
except Exception as e:
logger.error(f"connect_shared 失败: {e}")
return False
def disconnect_shared(self) -> None:
"""释放共享串口引用(与 connect_shared 对称)。
注意:不关闭串口本身,串口由 XYZController 负责关闭。
"""
try:
self.pipette.serial_port = None
self.pipette.lock = None
self.pipette.is_connected = False
self.xyz_controller = None
self.xyz_connected = False
logger.info("移液控制器已释放共享串口引用")
except Exception as e:
logger.error(f"disconnect_shared 失败: {e}")
def initialize(self) -> bool:
"""初始化移液器"""
try:
if self.pipette.initialize():
logger.info("移液器初始化成功")
# 检查枪头状态
self._update_tip_status()
self.xyz_controller.home_all_axes()
self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0)
return True
return False
except Exception as e:
@@ -198,56 +231,58 @@ class PipetteController:
def disconnect(self):
"""断开连接"""
# 断开移液器连接
if self.xyz_controller and self.xyz_connected:
if self.xyz_port != self.pipette_port:
try:
self.xyz_controller.disconnect()
logger.info("XYZ 步进电机已断开")
except Exception as e:
logger.error(f"断开 XYZ 步进电机失败: {e}")
else:
self.xyz_controller.serial_conn = None
self.xyz_connected = False
self.xyz_controller = None
self.pipette.disconnect()
logger.info("移液器已断开")
# 断开 XYZ 步进电机连接
if self.xyz_controller and self.xyz_connected:
try:
self.xyz_controller.disconnect()
self.xyz_connected = False
logger.info("XYZ 步进电机已断开")
except Exception as e:
logger.error(f"断开 XYZ 步进电机失败: {e}")
def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
"""
检查 XYZ 轴移动的安全性
Args:
axis: 电机轴
target_position: 目标位置(步数)
Returns:
是否安全
"""
try:
# 获取当前电机状态
motor_position = self.xyz_controller.get_motor_status(axis)
# 检查电机状态是否正常 (不是碰撞停止或限位停止)
if motor_position.status in [MotorStatus.COLLISION_STOP,
MotorStatus.FORWARD_LIMIT_STOP,
if motor_position.status in [MotorStatus.COLLISION_STOP,
MotorStatus.FORWARD_LIMIT_STOP,
MotorStatus.REVERSE_LIMIT_STOP]:
logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}")
return False
# 检查位置限制 (扩大安全范围以适应实际硬件)
# 步进电机的位置范围通常很大,这里设置更合理的范围
if target_position < -500000 or target_position > 500000:
logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}")
return False
# 检查移动距离是否过大 (单次移动不超过 20000 步约12mm)
current_position = motor_position.steps
move_distance = abs(target_position - current_position)
if move_distance > 20000:
logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}")
return False
return True
except Exception as e:
logger.error(f"安全检查失败: {e}")
return False
@@ -255,48 +290,48 @@ class PipetteController:
def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool:
"""
Z轴相对移动
Args:
distance_mm: 移动距离(mm),正值向下,负值向上
speed: 移动速度(rpm)
acceleration: 加速度(rpm/s)
Returns:
移动是否成功
"""
if not self.xyz_controller or not self.xyz_connected:
logger.error("XYZ 步进电机未连接,无法执行移动")
return False
try:
# 参数验证
if abs(distance_mm) > 15.0:
logger.error(f"移动距离过大: {distance_mm}mm最大允许15mm")
return False
if speed < 100 or speed > 5000:
logger.error(f"速度参数无效: {speed}rpm范围应为100-5000")
return False
# 获取当前 Z 轴位置
current_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
current_z_position = current_status.steps
# 计算移动距离对应的步数 (1mm = 1638.4步)
mm_to_steps = 1638.4
move_distance_steps = int(distance_mm * mm_to_steps)
# 计算目标位置
target_z_position = current_z_position + move_distance_steps
# 安全检查
if not self._check_xyz_safety(MotorAxis.Z, target_z_position):
logger.error("Z轴移动安全检查失败")
return False
logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)")
logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}")
# 执行移动
success = self.xyz_controller.move_to_position(
axis=MotorAxis.Z,
@@ -305,28 +340,28 @@ class PipetteController:
acceleration=acceleration,
precision=50
)
if not success:
logger.error("Z轴移动命令发送失败")
return False
# 等待移动完成
if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0):
logger.error("Z轴移动超时")
return False
# 验证移动结果
final_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
final_position = final_status.steps
position_error = abs(final_position - target_z_position)
logger.info(f"Z轴移动完成最终位置: {final_position}步,误差: {position_error}")
if position_error > 100:
logger.warning(f"Z轴位置误差较大: {position_error}")
return True
except ModbusException as e:
logger.error(f"Modbus通信错误: {e}")
return False
@@ -337,21 +372,20 @@ class PipetteController:
def emergency_stop(self) -> bool:
"""
紧急停止所有运动
Returns:
停止是否成功
"""
success = True
# 停止移液器操作
try:
if self.pipette and self.connected:
# 这里可以添加移液器的紧急停止逻辑
if self.pipette and self.pipette.is_connected:
self.pipette.emergency_stop()
logger.info("移液器紧急停止")
except Exception as e:
logger.error(f"移液器紧急停止失败: {e}")
success = False
# 停止 XYZ 轴运动
try:
if self.xyz_controller and self.xyz_connected:
@@ -360,7 +394,7 @@ class PipetteController:
except Exception as e:
logger.error(f"XYZ 轴紧急停止失败: {e}")
success = False
return success
def pickup_tip(self) -> bool:
@@ -376,7 +410,7 @@ class PipetteController:
return True
logger.info("开始装载枪头 - Z轴向下移动10mm")
# 使用相对移动方法向下移动10mm
if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500):
# 更新枪头状态
@@ -688,31 +722,31 @@ class PipetteController:
if __name__ == "__main__":
# 配置日志
import logging
# 设置日志级别
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def interactive_test():
"""交互式测试模式 - 适用于已连接的设备"""
print("\n" + "=" * 60)
print("🧪 移液器交互式测试模式")
print("=" * 60)
# 获取用户输入的连接参数
print("\n📡 设备连接配置:")
port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
address_input = input("请输入移液器设备地址 (默认: 4): ").strip()
address = int(address_input) if address_input else 4
# 询问是否连接 XYZ 步进电机控制器
xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower()
xyz_port = None
if xyz_enable not in ['n', 'no']:
xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
try:
# 创建移液控制器实例
if xyz_port:
@@ -721,21 +755,21 @@ if __name__ == "__main__":
else:
print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...")
pipette = PipetteController(port=port, address=address)
# 连接设备
print("\n📞 连接移液器设备...")
if not pipette.connect():
print("❌ 设备连接失败,请检查连接")
return
print("✅ 设备连接成功")
# 初始化设备
print("\n🚀 初始化设备...")
if not pipette.initialize():
print("❌ 设备初始化失败")
return
print("✅ 设备初始化成功")
# 交互式菜单
while True:
print("\n" + "=" * 50)
@@ -755,9 +789,9 @@ if __name__ == "__main__":
print("99. 🚨 紧急停止")
print("0. 🚪 退出程序")
print("=" * 50)
choice = input("\n请选择操作 (0-12, 99): ").strip()
if choice == "0":
print("\n👋 退出程序...")
break
@@ -773,7 +807,7 @@ if __name__ == "__main__":
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
elif choice == "2":
# 装载枪头
print("\n🔧 装载枪头...")
@@ -781,14 +815,14 @@ if __name__ == "__main__":
print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)")
else:
print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载")
if pipette.pickup_tip():
print("✅ 枪头装载成功")
if pipette.xyz_connected:
print("📍 Z 轴已移动到装载位置")
else:
print("❌ 枪头装载失败")
elif choice == "3":
# 弹出枪头
print("\n🗑️ 弹出枪头...")
@@ -796,7 +830,7 @@ if __name__ == "__main__":
print("✅ 枪头弹出成功")
else:
print("❌ 枪头弹出失败")
elif choice == "4":
# 吸液操作
try:
@@ -810,7 +844,7 @@ if __name__ == "__main__":
print("❌ 吸液失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "5":
# 排液操作
try:
@@ -824,7 +858,7 @@ if __name__ == "__main__":
print("❌ 排液失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "6":
# 混合操作
try:
@@ -838,7 +872,7 @@ if __name__ == "__main__":
print("❌ 混合失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "7":
# 液体转移
try:
@@ -846,7 +880,7 @@ if __name__ == "__main__":
source = input("源孔位 (可选, 如A1): ").strip() or None
dest = input("目标孔位 (可选, 如B1): ").strip() or None
new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n'
print(f"\n🔄 执行液体转移 ({volume}ul)...")
if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip):
print("✅ 液体转移完成")
@@ -854,7 +888,7 @@ if __name__ == "__main__":
print("❌ 液体转移失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "8":
# 设置液体类型
print("\n🧪 可用液体类型:")
@@ -864,16 +898,16 @@ if __name__ == "__main__":
"3": (LiquidClass.VISCOUS, "粘稠液体"),
"4": (LiquidClass.VOLATILE, "挥发性液体")
}
for key, (liquid_class, description) in liquid_options.items():
print(f" {key}. {description}")
liquid_choice = input("请选择液体类型 (1-4): ").strip()
if liquid_choice in liquid_options:
liquid_class, description = liquid_options[liquid_choice]
pipette.set_liquid_class(liquid_class)
print(f"✅ 液体类型设置为: {description}")
# 显示参数
params = pipette.liquid_params
print(f"📋 参数设置:")
@@ -883,7 +917,7 @@ if __name__ == "__main__":
print(f" 💧 预润湿: {'' if params.pre_wet else ''}")
else:
print("❌ 无效选择")
elif choice == "9":
# 自定义参数
try:
@@ -892,19 +926,19 @@ if __name__ == "__main__":
dispense_speed = input("排液速度 (默认800): ").strip()
air_gap = input("空气间隙 (ul, 默认10.0): ").strip()
pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y'
custom_params = LiquidParameters(
aspirate_speed=int(aspirate_speed) if aspirate_speed else 500,
dispense_speed=int(dispense_speed) if dispense_speed else 800,
air_gap=float(air_gap) if air_gap else 10.0,
pre_wet=pre_wet
)
pipette.set_custom_parameters(custom_params)
print("✅ 自定义参数设置完成")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "10":
# 校准体积
try:
@@ -914,12 +948,12 @@ if __name__ == "__main__":
print(f"✅ 校准完成,校准系数: {actual/expected:.3f}")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "11":
# 重置统计
pipette.reset_statistics()
print("✅ 统计信息已重置")
elif choice == "12":
# 液体类型测试
print("\n🧪 液体类型参数对比:")
@@ -929,7 +963,7 @@ if __name__ == "__main__":
(LiquidClass.VISCOUS, "粘稠液体"),
(LiquidClass.VOLATILE, "挥发性液体")
]
for liquid_class, description in liquid_tests:
params = pipette.LIQUID_PARAMS[liquid_class]
print(f"\n📋 {description} ({liquid_class.value}):")
@@ -938,7 +972,7 @@ if __name__ == "__main__":
print(f" 💨 空气间隙: {params.air_gap}ul")
print(f" 💧 预润湿: {'' if params.pre_wet else ''}")
print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s")
elif choice == "99":
# 紧急停止
print("\n🚨 执行紧急停止...")
@@ -949,19 +983,19 @@ if __name__ == "__main__":
else:
print("❌ 紧急停止执行失败")
print("⚠️ 请手动检查设备状态并采取必要措施")
# 紧急停止后询问是否继续
continue_choice = input("\n是否继续操作?(y/n): ").strip().lower()
if continue_choice != 'y':
print("🚪 退出程序")
break
else:
print("❌ 无效选择,请重新输入")
# 等待用户确认继续
input("\n按回车键继续...")
except KeyboardInterrupt:
print("\n\n⚠️ 用户中断操作")
except Exception as e:
@@ -974,19 +1008,19 @@ if __name__ == "__main__":
print("✅ 连接已断开")
except:
print("⚠️ 断开连接时出现问题")
def demo_test():
"""演示测试模式 - 完整功能演示"""
print("\n" + "=" * 60)
print("🎬 移液控制器演示测试")
print("=" * 60)
try:
# 创建移液控制器实例
print("1. 🔧 创建移液控制器实例...")
pipette = PipetteController(port="/dev/ttyUSB0", address=4)
print("✅ 移液控制器实例创建成功")
# 连接设备
print("\n2. 📞 连接移液器设备...")
if pipette.connect():
@@ -994,7 +1028,7 @@ if __name__ == "__main__":
else:
print("❌ 设备连接失败")
return False
# 初始化设备
print("\n3. 🚀 初始化设备...")
if pipette.initialize():
@@ -1002,19 +1036,19 @@ if __name__ == "__main__":
else:
print("❌ 设备初始化失败")
return False
# 装载枪头
print("\n4. 🔧 装载枪头...")
if pipette.pickup_tip():
print("✅ 枪头装载成功")
else:
print("❌ 枪头装载失败")
# 设置液体类型
print("\n5. 🧪 设置液体类型为血清...")
pipette.set_liquid_class(LiquidClass.SERUM)
print("✅ 液体类型设置完成")
# 吸液操作
print("\n6. 💧 执行吸液操作...")
volume_to_aspirate = 100.0
@@ -1023,7 +1057,7 @@ if __name__ == "__main__":
print(f"📊 当前体积: {pipette.current_volume}ul")
else:
print("❌ 吸液失败")
# 排液操作
print("\n7. 💦 执行排液操作...")
volume_to_dispense = 50.0
@@ -1032,14 +1066,14 @@ if __name__ == "__main__":
print(f"📊 剩余体积: {pipette.current_volume}ul")
else:
print("❌ 排液失败")
# 混合操作
print("\n8. 🌀 执行混合操作...")
if pipette.mix(cycles=3, volume=30.0):
print("✅ 混合完成")
else:
print("❌ 混合失败")
# 获取状态信息
print("\n9. 📊 获取设备状态...")
status = pipette.get_status()
@@ -1052,30 +1086,30 @@ if __name__ == "__main__":
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
# 弹出枪头
print("\n10. 🗑️ 弹出枪头...")
if pipette.eject_tip():
print("✅ 枪头弹出成功")
else:
print("❌ 枪头弹出失败")
print("\n" + "=" * 60)
print("✅ 移液控制器演示测试完成")
print("=" * 60)
return True
except Exception as e:
print(f"\n❌ 测试过程中发生异常: {e}")
return False
finally:
# 断开连接
print("\n📞 断开连接...")
pipette.disconnect()
print("✅ 连接已断开")
# 主程序入口
print("🧪 移液器控制器测试程序")
print("=" * 40)
@@ -1083,9 +1117,9 @@ if __name__ == "__main__":
print("2. 🎬 演示测试")
print("0. 🚪 退出")
print("=" * 40)
mode = input("请选择测试模式 (0-2): ").strip()
if mode == "1":
interactive_test()
elif mode == "2":
@@ -1094,7 +1128,7 @@ if __name__ == "__main__":
print("👋 再见!")
else:
print("❌ 无效选择")
print("\n🎉 程序结束!")
print("\n💡 使用说明:")
print("1. 确保移液器硬件已正确连接")

View File

@@ -13,7 +13,7 @@ from pylabrobot.liquid_handling import (
SingleChannelDispense,
PickupTipRack,
DropTipRack,
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
MultiHeadAspirationPlate,
)
from pylabrobot.liquid_handling.standard import (
MultiHeadAspirationContainer,
@@ -41,12 +41,6 @@ class TransformXYZDeck(Deck):
super().__init__(name, size_x, size_y, size_z)
self.name = name
class TransformXYZBackend(LiquidHandlerBackend):
def __init__(self, name: str, host: str, port: int, timeout: float):
super().__init__()
self.host = host
self.port = port
self.timeout = timeout
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
def __init__(self, name: str, channel_num: int):
@@ -86,7 +80,9 @@ class TransformXYZContainer(Plate, TipRack):
class TransformXYZHandler(LiquidHandlerAbstract):
support_touch_tip = False
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs):
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True,
serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4,
total_height: float = 310, **backend_kwargs):
# Handle case where deck is passed as a dict (from serialization)
if isinstance(deck, dict):
# Try to create a TransformXYZDeck from the dict
@@ -102,11 +98,22 @@ class TransformXYZHandler(LiquidHandlerAbstract):
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
if simulator:
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num)
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num)
else:
self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout)
self._unilabos_backend = UniLiquidHandlerLaiyuBackend(
num_channels=channel_num,
total_height=total_height,
port=serial_port,
baudrate=baudrate,
pipette_address=pipette_address,
)
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
def post_init(self, ros_node):
super().post_init(ros_node)
if hasattr(self._unilabos_backend, 'post_init'):
self._unilabos_backend.post_init(ros_node)
async def add_liquid(
self,
asp_vols: Union[List[float], float],
@@ -128,7 +135,25 @@ class TransformXYZHandler(LiquidHandlerAbstract):
mix_liquid_height: Optional[float] = None,
none_keys: List[str] = [],
):
pass
return await super().add_liquid(
asp_vols=asp_vols,
dis_vols=dis_vols,
reagent_sources=reagent_sources,
targets=targets,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
delays=delays,
mix_time=mix_time,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
none_keys=none_keys,
)
async def aspirate(
self,
@@ -142,7 +167,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
pass
return await super().aspirate(
resources=resources,
vols=vols,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
**backend_kwargs,
)
async def dispense(
self,
@@ -156,7 +191,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
pass
return await super().dispense(
resources=resources,
vols=vols,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
**backend_kwargs,
)
async def drop_tips(
self,
@@ -166,7 +211,13 @@ class TransformXYZHandler(LiquidHandlerAbstract):
allow_nonzero_volume: bool = False,
**backend_kwargs,
):
pass
return await super().drop_tips(
tip_spots=tip_spots,
use_channels=use_channels,
offsets=offsets,
allow_nonzero_volume=allow_nonzero_volume,
**backend_kwargs,
)
async def mix(
self,
@@ -178,7 +229,15 @@ class TransformXYZHandler(LiquidHandlerAbstract):
mix_rate: Optional[float] = None,
none_keys: List[str] = [],
):
pass
return await super().mix(
targets=targets,
mix_time=mix_time,
mix_vol=mix_vol,
height_to_bottom=height_to_bottom,
offsets=offsets,
mix_rate=mix_rate,
none_keys=none_keys,
)
async def pick_up_tips(
self,
@@ -187,7 +246,12 @@ class TransformXYZHandler(LiquidHandlerAbstract):
offsets: Optional[List[Coordinate]] = None,
**backend_kwargs,
):
pass
return await super().pick_up_tips(
tip_spots=tip_spots,
use_channels=use_channels,
offsets=offsets,
**backend_kwargs,
)
async def transfer_liquid(
self,
@@ -214,5 +278,26 @@ class TransformXYZHandler(LiquidHandlerAbstract):
delays: Optional[List[int]] = None,
none_keys: List[str] = [],
):
pass
return await super().transfer_liquid(
sources=sources,
targets=targets,
tip_racks=tip_racks,
use_channels=use_channels,
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
touch_tip=touch_tip,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
mix_stage=mix_stage,
mix_times=mix_times,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
delays=delays,
none_keys=none_keys,
)

View File

@@ -57,6 +57,18 @@ class TransferLiquidReturn(TypedDict):
targets: List[List[ResourceDict]]
class SetLiquidReturn(TypedDict):
wells: list
volumes: list
class SetLiquidFromPlateReturn(TypedDict):
plate: list
wells: list
volumes: list
class LiquidHandlerMiddleware(LiquidHandler):
def __init__(
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs

View File

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

View File

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

View File

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

View File

@@ -1,127 +0,0 @@
"""
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 载具资源")

View File

@@ -2,6 +2,8 @@ import time
import logging
from typing import Union, Dict, Optional
from unilabos.registry.decorators import topic_config
class VirtualMultiwayValve:
"""
@@ -41,13 +43,11 @@ class VirtualMultiwayValve:
def target_position(self) -> int:
return self._target_position
def get_current_position(self) -> int:
"""获取当前阀门位置 📍"""
return self._current_position
def get_current_port(self) -> str:
"""获取当前连接的端口名称 🔌"""
return self._current_position
@property
@topic_config()
def current_port(self) -> str:
"""当前连接的端口名称 🔌"""
return self.port
def set_position(self, command: Union[int, str]):
"""
@@ -169,12 +169,14 @@ class VirtualMultiwayValve:
self._status = "Idle"
self._valve_state = "Closed"
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})"
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
self.logger.info(close_msg)
return close_msg
def get_valve_position(self) -> int:
"""获取阀门位置 - 兼容性方法 📍"""
@property
@topic_config()
def valve_position(self) -> int:
"""阀门位置 📍"""
return self._current_position
def set_valve_position(self, command: Union[int, str]):
@@ -229,19 +231,16 @@ class VirtualMultiwayValve:
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
return self.set_to_pump_position()
def get_flow_path(self) -> str:
"""获取当前流路路径描述 🌊"""
current_port = self.get_current_port()
@property
@topic_config()
def flow_path(self) -> str:
"""当前流路路径描述 🌊"""
if self._current_position == 0:
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})"
else:
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
# 删除debug日志self.logger.debug(f"🌊 当前流路: {flow_path}")
return flow_path
return f"🚰 转移泵已连接 (位置 {self._current_position})"
return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
def __str__(self):
current_port = self.get_current_port()
current_port = self.current_port
status_emoji = "" if self._status == "Idle" else "🔄" if self._status == "Busy" else ""
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
@@ -253,7 +252,7 @@ if __name__ == "__main__":
print("🔄 === 虚拟九通阀门测试 === ✨")
print(f"🏠 初始状态: {valve}")
print(f"🌊 当前流路: {valve.get_flow_path()}")
print(f"🌊 当前流路: {valve.flow_path}")
# 切换到试剂瓶11号位
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")

View File

@@ -3,6 +3,7 @@ import logging
import time as time_module
from typing import Dict, Any
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualStirrer:
@@ -314,9 +315,11 @@ class VirtualStirrer:
def min_speed(self) -> float:
return self._min_speed
def get_device_info(self) -> Dict[str, Any]:
"""获取设备状态信息 📊"""
info = {
@property
@topic_config()
def device_info(self) -> Dict[str, Any]:
"""设备状态快照信息 📊"""
return {
"device_id": self.device_id,
"status": self.status,
"operation_mode": self.operation_mode,
@@ -325,12 +328,9 @@ class VirtualStirrer:
"is_stirring": self.is_stirring,
"remaining_time": self.remaining_time,
"max_speed": self._max_speed,
"min_speed": self._min_speed
"min_speed": self._min_speed,
}
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
return info
def __str__(self):
status_emoji = "" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else ""
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"

View File

@@ -4,6 +4,7 @@ from enum import Enum
from typing import Union, Optional
import logging
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -385,8 +386,10 @@ class VirtualTransferPump:
"""获取当前体积"""
return self._current_volume
def get_remaining_capacity(self) -> float:
"""获取剩余容量"""
@property
@topic_config()
def remaining_capacity(self) -> float:
"""剩余容量 (ml)"""
return self.max_volume - self._current_volume
def is_empty(self) -> bool:

View File

@@ -22,10 +22,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
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
)
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
# ============ TypedDict 返回类型定义 ============
@@ -290,6 +291,126 @@ class VirtualWorkbench:
self._update_data_status(f"机械臂已释放 (完成: {task})")
self.logger.info(f"机械臂已释放 (完成: {task})")
@action(
always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
"assignee_user_ids": "unilabos_manual_confirm"
}, goal_default={
"timeout_seconds": 3600,
"assignee_user_ids": []
}, feedback_interval=300,
handles=[
ActionInputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
ActionInputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
ActionInputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
ActionInputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
# transfer使用
ActionOutputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR),
# test使用
ActionOutputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR),
]
)
def manual_confirm(
self,
resource: List[ResourceSlot],
target_device: DeviceSlot,
mount_resource: List[ResourceSlot],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: List[str],
timeout_seconds: int,
assignee_user_ids: list[str],
**kwargs
) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
collector_mass: 极流体质量
active_material: 活性物质含量
capacity: 克容量mAh/g
battery_system: 电池体系
修改的结果无效,是只读的
"""
resource = ResourceTreeSet.from_plr_resources(resource).dump()
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
kwargs.update(locals())
kwargs.pop("kwargs")
kwargs.pop("self")
return kwargs
@action(
description="转移物料",
handles=[
ActionInputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
]
)
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
**{
"plr_resources": resource,
"target_device_id": target_device,
"target_resources": mount_resource,
"sites": [None] * len(mount_resource),
})
result = await future
return result
@action(
description="扣电测试启动",
handles=[
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
ActionInputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
ActionInputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
ActionInputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
]
)
async def test(
self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str]
):
print(resource)
print(mount_resource)
print(collector_mass)
print(active_material)
print(capacity)
print(battery_system)
@action(
auto_prefix=True,
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",

View File

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

View File

@@ -197,28 +197,6 @@ 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:

View File

@@ -217,24 +217,6 @@ 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
@@ -647,16 +629,15 @@ class HydrogenateProtocol(BaseModel):
vessel: dict = Field(..., description="反应容器")
__all__ = [
"Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol",
"EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol",
"BatchTransferItem", "BatchTransferProtocol",
"CentrifugeProtocol", "AddProtocol", "FilterProtocol",
"Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol",
"EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol",
"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

View File

@@ -825,6 +825,7 @@ def _extract_class_body(
action_args.setdefault("placeholder_keys", {})
action_args.setdefault("always_free", False)
action_args.setdefault("is_protocol", False)
action_args.setdefault("feedback_interval", 1.0)
action_args.setdefault("description", "")
action_args.setdefault("auto_prefix", False)
action_args.setdefault("parent", False)

Some files were not shown because too many files have changed in this diff Show More