mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 16:49:17 +00:00
Compare commits
18 Commits
prcix9320
...
47a29a0c2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47a29a0c2f | ||
|
|
9c6f7c7505 | ||
|
|
514373c164 | ||
|
|
fcea02585a | ||
|
|
07cf690897 | ||
|
|
cfea27460a | ||
|
|
b7d3e980a9 | ||
|
|
f9ed6cb3fb | ||
|
|
e4e4bfbe20 | ||
|
|
64c748d921 | ||
|
|
15ff0e9d30 | ||
|
|
f8a52860ad | ||
|
|
e30c01d54e | ||
|
|
37ec49f318 | ||
|
|
6bf57f18c1 | ||
|
|
c4a3be1498 | ||
|
|
e11070315d | ||
|
|
50ebcad9d7 |
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.17
|
||||
version: 0.10.18
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
@@ -46,13 +46,15 @@ requirements:
|
||||
- jinja2
|
||||
- requests
|
||||
- uvicorn
|
||||
- opcua # [not osx]
|
||||
- if: not osx
|
||||
then:
|
||||
- opcua
|
||||
- pyserial
|
||||
- pandas
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.10.17
|
||||
- uni-lab::unilabos-env ==0.10.18
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.17
|
||||
version: 0.10.18
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.10.17
|
||||
version: 0.10.18
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
@@ -11,7 +11,7 @@ build:
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.10.17
|
||||
- uni-lab::unilabos ==0.10.18
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM upgrade pip
|
||||
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
||||
|
||||
REM install extra deps
|
||||
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
||||
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euxo pipefail
|
||||
|
||||
# make sure pip is available
|
||||
"$PREFIX/bin/python" -m pip install --upgrade pip
|
||||
|
||||
# install extra deps
|
||||
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
||||
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
328
.cursor/rules/device-drivers.mdc
Normal file
328
.cursor/rules/device-drivers.mdc
Normal file
@@ -0,0 +1,328 @@
|
||||
---
|
||||
description: 设备驱动开发规范
|
||||
globs: ["unilabos/devices/**/*.py"]
|
||||
---
|
||||
|
||||
# 设备驱动开发规范
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
unilabos/devices/
|
||||
├── virtual/ # 虚拟设备(用于测试)
|
||||
│ ├── virtual_stirrer.py
|
||||
│ └── virtual_centrifuge.py
|
||||
├── liquid_handling/ # 液体处理设备
|
||||
├── balance/ # 天平设备
|
||||
├── hplc/ # HPLC设备
|
||||
├── pump_and_valve/ # 泵和阀门
|
||||
├── temperature/ # 温度控制设备
|
||||
├── workstation/ # 工作站(组合设备)
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 设备类完整模板
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import logging
|
||||
import time as time_module
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
class MyDevice:
|
||||
"""
|
||||
设备类描述
|
||||
|
||||
Attributes:
|
||||
device_id: 设备唯一标识
|
||||
config: 设备配置字典
|
||||
data: 设备状态数据
|
||||
"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str = None,
|
||||
config: Dict[str, Any] = None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
初始化设备
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
config: 配置字典
|
||||
**kwargs: 其他参数
|
||||
"""
|
||||
# 兼容不同调用方式
|
||||
if device_id is None and 'id' in kwargs:
|
||||
device_id = kwargs.pop('id')
|
||||
if config is None and 'config' in kwargs:
|
||||
config = kwargs.pop('config')
|
||||
|
||||
self.device_id = device_id or "unknown_device"
|
||||
self.config = config or {}
|
||||
self.data = {}
|
||||
|
||||
# 从config读取参数
|
||||
self.port = self.config.get('port') or kwargs.get('port', 'COM1')
|
||||
self._max_value = self.config.get('max_value', 1000.0)
|
||||
|
||||
# 初始化日志
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
|
||||
self.logger.info(f"设备 {self.device_id} 已创建")
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
"""
|
||||
ROS节点注入 - 在ROS节点创建后调用
|
||||
|
||||
Args:
|
||||
ros_node: ROS2设备节点实例
|
||||
"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""
|
||||
初始化设备 - 连接硬件、设置初始状态
|
||||
|
||||
Returns:
|
||||
bool: 初始化是否成功
|
||||
"""
|
||||
self.logger.info(f"初始化设备 {self.device_id}")
|
||||
|
||||
try:
|
||||
# 执行硬件初始化
|
||||
# await self._connect_hardware()
|
||||
|
||||
# 设置初始状态
|
||||
self.data.update({
|
||||
"status": "待机",
|
||||
"is_running": False,
|
||||
"current_value": 0.0,
|
||||
})
|
||||
|
||||
self.logger.info(f"设备 {self.device_id} 初始化完成")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"初始化失败: {e}")
|
||||
self.data["status"] = f"错误: {e}"
|
||||
return False
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
"""
|
||||
清理设备 - 断开连接、释放资源
|
||||
|
||||
Returns:
|
||||
bool: 清理是否成功
|
||||
"""
|
||||
self.logger.info(f"清理设备 {self.device_id}")
|
||||
|
||||
self.data.update({
|
||||
"status": "离线",
|
||||
"is_running": False,
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
# ==================== 设备动作 ====================
|
||||
|
||||
async def execute_action(
|
||||
self,
|
||||
param1: float,
|
||||
param2: str = "",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
执行设备动作
|
||||
|
||||
Args:
|
||||
param1: 参数1
|
||||
param2: 参数2(可选)
|
||||
|
||||
Returns:
|
||||
bool: 动作是否成功
|
||||
"""
|
||||
# 类型转换和验证
|
||||
try:
|
||||
param1 = float(param1)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.logger.error(f"参数类型错误: {e}")
|
||||
return False
|
||||
|
||||
# 参数验证
|
||||
if param1 > self._max_value:
|
||||
self.logger.error(f"参数超出范围: {param1} > {self._max_value}")
|
||||
return False
|
||||
|
||||
self.logger.info(f"执行动作: param1={param1}, param2={param2}")
|
||||
|
||||
# 更新状态
|
||||
self.data.update({
|
||||
"status": "运行中",
|
||||
"is_running": True,
|
||||
})
|
||||
|
||||
# 执行动作(带进度反馈)
|
||||
duration = 10.0 # 秒
|
||||
start_time = time_module.time()
|
||||
|
||||
while True:
|
||||
elapsed = time_module.time() - start_time
|
||||
remaining = max(0, duration - elapsed)
|
||||
progress = min(100, (elapsed / duration) * 100)
|
||||
|
||||
self.data.update({
|
||||
"status": f"运行中: {progress:.0f}%",
|
||||
"remaining_time": remaining,
|
||||
})
|
||||
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
await self._ros_node.sleep(1.0)
|
||||
|
||||
# 完成
|
||||
self.data.update({
|
||||
"status": "完成",
|
||||
"is_running": False,
|
||||
})
|
||||
|
||||
self.logger.info("动作执行完成")
|
||||
return True
|
||||
|
||||
# ==================== 状态属性 ====================
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""设备状态 - 自动发布为ROS Topic"""
|
||||
return self.data.get("status", "未知")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""是否正在运行"""
|
||||
return self.data.get("is_running", False)
|
||||
|
||||
@property
|
||||
def current_value(self) -> float:
|
||||
"""当前值"""
|
||||
return self.data.get("current_value", 0.0)
|
||||
|
||||
# ==================== 辅助方法 ====================
|
||||
|
||||
def get_device_info(self) -> Dict[str, Any]:
|
||||
"""获取设备信息"""
|
||||
return {
|
||||
"device_id": self.device_id,
|
||||
"status": self.status,
|
||||
"is_running": self.is_running,
|
||||
"current_value": self.current_value,
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"MyDevice({self.device_id}: {self.status})"
|
||||
```
|
||||
|
||||
## 关键规则
|
||||
|
||||
### 1. 参数处理
|
||||
|
||||
所有动作方法的参数都可能以字符串形式传入,必须进行类型转换:
|
||||
|
||||
```python
|
||||
async def my_action(self, value: float, **kwargs) -> bool:
|
||||
# 始终进行类型转换
|
||||
try:
|
||||
value = float(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.logger.error(f"参数类型错误: {e}")
|
||||
return False
|
||||
```
|
||||
|
||||
### 2. vessel 参数处理
|
||||
|
||||
vessel 参数可能是字符串ID或字典:
|
||||
|
||||
```python
|
||||
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||
if isinstance(vessel, dict):
|
||||
return vessel.get("id", "")
|
||||
return str(vessel) if vessel else ""
|
||||
```
|
||||
|
||||
### 3. 状态更新
|
||||
|
||||
使用 `self.data` 字典存储状态,属性读取状态:
|
||||
|
||||
```python
|
||||
# 更新状态
|
||||
self.data["status"] = "运行中"
|
||||
self.data["current_speed"] = 300.0
|
||||
|
||||
# 读取状态(通过属性)
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "待机")
|
||||
```
|
||||
|
||||
### 4. 异步等待
|
||||
|
||||
使用 ROS 节点的 sleep 方法:
|
||||
|
||||
```python
|
||||
# 正确
|
||||
await self._ros_node.sleep(1.0)
|
||||
|
||||
# 避免(除非在纯 Python 测试环境)
|
||||
await asyncio.sleep(1.0)
|
||||
```
|
||||
|
||||
### 5. 进度反馈
|
||||
|
||||
长时间运行的操作需要提供进度反馈:
|
||||
|
||||
```python
|
||||
while remaining > 0:
|
||||
progress = (elapsed / total_time) * 100
|
||||
self.data["status"] = f"运行中: {progress:.0f}%"
|
||||
self.data["remaining_time"] = remaining
|
||||
|
||||
await self._ros_node.sleep(1.0)
|
||||
```
|
||||
|
||||
## 虚拟设备
|
||||
|
||||
虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录:
|
||||
|
||||
- 类名以 `Virtual` 开头
|
||||
- 文件名以 `virtual_` 开头
|
||||
- 模拟真实设备的行为和时序
|
||||
- 使用表情符号增强日志可读性(可选)
|
||||
|
||||
## 工作站设备
|
||||
|
||||
工作站是组合多个设备的复杂设备:
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
"""组合工作站"""
|
||||
|
||||
async def execute_workflow(self, workflow: Dict[str, Any]) -> bool:
|
||||
"""执行工作流"""
|
||||
pass
|
||||
```
|
||||
|
||||
## 设备注册
|
||||
|
||||
设备类开发完成后,需要在注册表中注册:
|
||||
|
||||
1. 创建/编辑 `unilabos/registry/devices/my_category.yaml`
|
||||
2. 添加设备配置(参考 `virtual_device.yaml`)
|
||||
3. 运行 `--complete_registry` 自动生成 schema
|
||||
240
.cursor/rules/protocol-development.mdc
Normal file
240
.cursor/rules/protocol-development.mdc
Normal file
@@ -0,0 +1,240 @@
|
||||
---
|
||||
description: 协议编译器开发规范
|
||||
globs: ["unilabos/compile/**/*.py"]
|
||||
---
|
||||
|
||||
# 协议编译器开发规范
|
||||
|
||||
## 概述
|
||||
|
||||
协议编译器负责将高级实验操作(如 Stir、Add、Filter)编译为设备可执行的动作序列。
|
||||
|
||||
## 文件命名
|
||||
|
||||
- 位置: `unilabos/compile/`
|
||||
- 命名: `{operation}_protocol.py`
|
||||
- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py`
|
||||
|
||||
## 协议函数模板
|
||||
|
||||
```python
|
||||
from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
|
||||
from .utils.unit_parser import parse_time_input
|
||||
from .utils.vessel_parser import extract_vessel_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_{operation}_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: Union[str, dict],
|
||||
param1: Union[str, float] = "0",
|
||||
param2: float = 0.0,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成{操作}协议序列
|
||||
|
||||
Args:
|
||||
G: 物理拓扑图 (NetworkX DiGraph)
|
||||
vessel: 容器ID或Resource字典
|
||||
param1: 参数1(支持字符串单位,如 "5 min")
|
||||
param2: 参数2
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
List[Dict]: 动作序列
|
||||
|
||||
Raises:
|
||||
ValueError: 参数无效时
|
||||
"""
|
||||
# 1. 提取 vessel_id
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
|
||||
# 2. 验证参数
|
||||
if not vessel_id:
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
# 3. 解析参数(支持单位)
|
||||
parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0
|
||||
|
||||
# 4. 查找设备
|
||||
device_id = find_connected_device(G, vessel_id, device_type="my_device")
|
||||
|
||||
# 5. 生成动作序列
|
||||
action_sequence = []
|
||||
|
||||
action = {
|
||||
"device_id": device_id,
|
||||
"action_name": "my_action",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 始终使用字典格式
|
||||
"param1": float(parsed_param1),
|
||||
"param2": float(param2),
|
||||
}
|
||||
}
|
||||
action_sequence.append(action)
|
||||
|
||||
logger.info(f"生成协议: {len(action_sequence)} 个动作")
|
||||
return action_sequence
|
||||
|
||||
|
||||
def find_connected_device(
|
||||
G: nx.DiGraph,
|
||||
vessel_id: str,
|
||||
device_type: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
查找与容器相连的设备
|
||||
|
||||
Args:
|
||||
G: 拓扑图
|
||||
vessel_id: 容器ID
|
||||
device_type: 设备类型关键字
|
||||
|
||||
Returns:
|
||||
str: 设备ID
|
||||
"""
|
||||
# 查找所有匹配类型的设备
|
||||
device_nodes = []
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '') or ''
|
||||
if device_type.lower() in node_class.lower():
|
||||
device_nodes.append(node)
|
||||
|
||||
# 检查连接
|
||||
if vessel_id and device_nodes:
|
||||
for device in device_nodes:
|
||||
if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device):
|
||||
return device
|
||||
|
||||
# 返回第一个可用设备
|
||||
if device_nodes:
|
||||
return device_nodes[0]
|
||||
|
||||
# 默认设备
|
||||
return f"{device_type}_1"
|
||||
```
|
||||
|
||||
## 关键规则
|
||||
|
||||
### 1. vessel 参数处理
|
||||
|
||||
vessel 参数可能是字符串或字典,需要统一处理:
|
||||
|
||||
```python
|
||||
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||
"""提取vessel_id"""
|
||||
if isinstance(vessel, dict):
|
||||
# 可能是 {"id": "xxx"} 或完整 Resource 对象
|
||||
return vessel.get("id", list(vessel.values())[0].get("id", ""))
|
||||
return str(vessel) if vessel else ""
|
||||
```
|
||||
|
||||
### 2. action_kwargs 中的 vessel
|
||||
|
||||
始终使用 `{"id": vessel_id}` 格式传递 vessel:
|
||||
|
||||
```python
|
||||
# 正确
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 字符串ID包装为字典
|
||||
}
|
||||
|
||||
# 避免
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_resource, # 不要传递完整 Resource 对象
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 单位解析
|
||||
|
||||
使用 `parse_time_input` 解析时间参数:
|
||||
|
||||
```python
|
||||
from .utils.unit_parser import parse_time_input
|
||||
|
||||
# 支持格式: "5 min", "1 h", "300", "1.5 hours"
|
||||
time_seconds = parse_time_input("5 min") # -> 300.0
|
||||
time_seconds = parse_time_input(120) # -> 120.0
|
||||
time_seconds = parse_time_input("1 h") # -> 3600.0
|
||||
```
|
||||
|
||||
### 4. 参数验证
|
||||
|
||||
所有参数必须进行验证和类型转换:
|
||||
|
||||
```python
|
||||
# 验证范围
|
||||
if speed < 10.0 or speed > 1500.0:
|
||||
logger.warning(f"速度 {speed} 超出范围,修正为 300")
|
||||
speed = 300.0
|
||||
|
||||
# 类型转换
|
||||
param = float(param) if not isinstance(param, (int, float)) else param
|
||||
```
|
||||
|
||||
### 5. 日志记录
|
||||
|
||||
使用项目日志记录器:
|
||||
|
||||
```python
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def generate_protocol(...):
|
||||
logger.info(f"开始生成协议...")
|
||||
logger.debug(f"参数: vessel={vessel_id}, time={time}")
|
||||
logger.warning(f"参数修正: {old_value} -> {new_value}")
|
||||
```
|
||||
|
||||
## 便捷函数
|
||||
|
||||
为常用操作提供便捷函数:
|
||||
|
||||
```python
|
||||
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
speed: float = 300.0) -> List[Dict[str, Any]]:
|
||||
"""短时间搅拌(30秒)"""
|
||||
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
|
||||
|
||||
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: str = "5 min") -> List[Dict[str, Any]]:
|
||||
"""剧烈搅拌"""
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
|
||||
```
|
||||
|
||||
## 测试函数
|
||||
|
||||
每个协议文件应包含测试函数:
|
||||
|
||||
```python
|
||||
def test_{operation}_protocol():
|
||||
"""测试协议生成"""
|
||||
# 测试参数处理
|
||||
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
|
||||
vessel_id = extract_vessel_id(vessel_dict)
|
||||
assert vessel_id == "flask_1"
|
||||
|
||||
# 测试单位解析
|
||||
time_s = parse_time_input("5 min")
|
||||
assert time_s == 300.0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_{operation}_protocol()
|
||||
```
|
||||
|
||||
## 现有协议参考
|
||||
|
||||
- `stir_protocol.py` - 搅拌操作
|
||||
- `add_protocol.py` - 添加物料
|
||||
- `filter_protocol.py` - 过滤操作
|
||||
- `heatchill_protocol.py` - 加热/冷却
|
||||
- `separate_protocol.py` - 分离操作
|
||||
- `evaporate_protocol.py` - 蒸发操作
|
||||
319
.cursor/rules/registry-config.mdc
Normal file
319
.cursor/rules/registry-config.mdc
Normal file
@@ -0,0 +1,319 @@
|
||||
---
|
||||
description: 注册表配置规范 (YAML)
|
||||
globs: ["unilabos/registry/**/*.yaml"]
|
||||
---
|
||||
|
||||
# 注册表配置规范
|
||||
|
||||
## 概述
|
||||
|
||||
注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
unilabos/registry/
|
||||
├── devices/ # 设备类型注册
|
||||
│ ├── virtual_device.yaml
|
||||
│ ├── liquid_handler.yaml
|
||||
│ └── ...
|
||||
├── device_comms/ # 通信设备配置
|
||||
│ ├── communication_devices.yaml
|
||||
│ └── modbus_ioboard.yaml
|
||||
└── resources/ # 资源类型注册
|
||||
├── bioyond/
|
||||
├── organic/
|
||||
├── opentrons/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 设备注册表格式
|
||||
|
||||
### 基本结构
|
||||
|
||||
```yaml
|
||||
device_type_id:
|
||||
# 基本信息
|
||||
description: "设备描述"
|
||||
version: "1.0.0"
|
||||
category:
|
||||
- category_name
|
||||
icon: "icon_device.webp"
|
||||
|
||||
# 类配置
|
||||
class:
|
||||
module: "unilabos.devices.my_module:MyClass"
|
||||
type: python
|
||||
|
||||
# 状态类型(属性 -> ROS消息类型)
|
||||
status_types:
|
||||
status: String
|
||||
temperature: Float64
|
||||
is_running: Bool
|
||||
|
||||
# 动作映射
|
||||
action_value_mappings:
|
||||
action_name:
|
||||
type: UniLabJsonCommand # 或 UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema: {...}
|
||||
handles: {}
|
||||
```
|
||||
|
||||
### action_value_mappings 详细格式
|
||||
|
||||
```yaml
|
||||
action_value_mappings:
|
||||
# 同步动作
|
||||
my_sync_action:
|
||||
type: UniLabJsonCommand
|
||||
goal:
|
||||
param1: param1
|
||||
param2: param2
|
||||
feedback: {}
|
||||
result:
|
||||
success: success
|
||||
message: message
|
||||
goal_default:
|
||||
param1: 0.0
|
||||
param2: ""
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
device_param: unilabos_devices # 设备选择器
|
||||
resource_param: unilabos_resources # 资源选择器
|
||||
schema:
|
||||
title: "动作名称参数"
|
||||
description: "动作描述"
|
||||
type: object
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
properties:
|
||||
param1:
|
||||
type: number
|
||||
param2:
|
||||
type: string
|
||||
required:
|
||||
- param1
|
||||
feedback: {}
|
||||
result:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- goal
|
||||
|
||||
# 异步动作
|
||||
my_async_action:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback:
|
||||
progress: progress
|
||||
current_status: status
|
||||
result:
|
||||
success: success
|
||||
schema: {...}
|
||||
```
|
||||
|
||||
### 自动生成的动作
|
||||
|
||||
以 `auto-` 开头的动作由系统自动生成:
|
||||
|
||||
```yaml
|
||||
action_value_mappings:
|
||||
auto-initialize:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema: {...}
|
||||
|
||||
auto-cleanup:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema: {...}
|
||||
```
|
||||
|
||||
### handles 配置
|
||||
|
||||
用于工作流编辑器中的数据流连接:
|
||||
|
||||
```yaml
|
||||
handles:
|
||||
input:
|
||||
- handler_key: "input_resource"
|
||||
data_type: "resource"
|
||||
label: "输入资源"
|
||||
data_source: "handle"
|
||||
data_key: "resources"
|
||||
output:
|
||||
- handler_key: "output_labware"
|
||||
data_type: "resource"
|
||||
label: "输出器皿"
|
||||
data_source: "executor"
|
||||
data_key: "created_resource.@flatten"
|
||||
```
|
||||
|
||||
## 资源注册表格式
|
||||
|
||||
```yaml
|
||||
resource_type_id:
|
||||
description: "资源描述"
|
||||
version: "1.0.0"
|
||||
category:
|
||||
- category_name
|
||||
icon: ""
|
||||
handles: []
|
||||
init_param_schema: {}
|
||||
|
||||
class:
|
||||
module: "unilabos.resources.my_module:MyResource"
|
||||
type: pylabrobot # 或 python
|
||||
```
|
||||
|
||||
### PyLabRobot 资源示例
|
||||
|
||||
```yaml
|
||||
BIOYOND_Electrolyte_6VialCarrier:
|
||||
category:
|
||||
- bottle_carriers
|
||||
- bioyond
|
||||
class:
|
||||
module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier"
|
||||
type: pylabrobot
|
||||
version: "1.0.0"
|
||||
```
|
||||
|
||||
## 状态类型映射
|
||||
|
||||
Python 类型到 ROS 消息类型的映射:
|
||||
|
||||
| Python 类型 | ROS 消息类型 |
|
||||
|------------|-------------|
|
||||
| `str` | `String` |
|
||||
| `bool` | `Bool` |
|
||||
| `int` | `Int64` |
|
||||
| `float` | `Float64` |
|
||||
| `list` | `String` (序列化) |
|
||||
| `dict` | `String` (序列化) |
|
||||
|
||||
## 自动完善注册表
|
||||
|
||||
使用 `--complete_registry` 参数自动生成 schema:
|
||||
|
||||
```bash
|
||||
python -m unilabos.app.main --complete_registry
|
||||
```
|
||||
|
||||
这会:
|
||||
1. 扫描设备类的方法签名
|
||||
2. 自动生成 `auto-` 前缀的动作
|
||||
3. 生成 JSON Schema
|
||||
4. 更新 YAML 文件
|
||||
|
||||
## 验证规则
|
||||
|
||||
1. **device_type_id** 必须唯一
|
||||
2. **module** 路径必须正确可导入
|
||||
3. **status_types** 的类型必须是有效的 ROS 消息类型
|
||||
4. **schema** 必须是有效的 JSON Schema
|
||||
|
||||
## 示例:完整设备配置
|
||||
|
||||
```yaml
|
||||
virtual_stirrer:
|
||||
category:
|
||||
- virtual_device
|
||||
description: "虚拟搅拌器设备"
|
||||
version: "1.0.0"
|
||||
icon: "icon_stirrer.webp"
|
||||
handles: []
|
||||
init_param_schema: {}
|
||||
|
||||
class:
|
||||
module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer"
|
||||
type: python
|
||||
|
||||
status_types:
|
||||
status: String
|
||||
operation_mode: String
|
||||
current_speed: Float64
|
||||
is_stirring: Bool
|
||||
remaining_time: Float64
|
||||
|
||||
action_value_mappings:
|
||||
auto-initialize:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema:
|
||||
title: "initialize参数"
|
||||
type: object
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
properties: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
|
||||
stir:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal:
|
||||
stir_time: stir_time
|
||||
stir_speed: stir_speed
|
||||
settling_time: settling_time
|
||||
feedback:
|
||||
current_speed: current_speed
|
||||
remaining_time: remaining_time
|
||||
result:
|
||||
success: success
|
||||
goal_default:
|
||||
stir_time: 60.0
|
||||
stir_speed: 300.0
|
||||
settling_time: 30.0
|
||||
handles: {}
|
||||
schema:
|
||||
title: "stir参数"
|
||||
description: "搅拌操作"
|
||||
type: object
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
properties:
|
||||
stir_time:
|
||||
type: number
|
||||
description: "搅拌时间(秒)"
|
||||
stir_speed:
|
||||
type: number
|
||||
description: "搅拌速度(RPM)"
|
||||
settling_time:
|
||||
type: number
|
||||
description: "沉降时间(秒)"
|
||||
required:
|
||||
- stir_time
|
||||
- stir_speed
|
||||
feedback:
|
||||
type: object
|
||||
properties:
|
||||
current_speed:
|
||||
type: number
|
||||
remaining_time:
|
||||
type: number
|
||||
result:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
```
|
||||
233
.cursor/rules/ros-integration.mdc
Normal file
233
.cursor/rules/ros-integration.mdc
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
description: ROS 2 集成开发规范
|
||||
globs: ["unilabos/ros/**/*.py", "**/*_node.py"]
|
||||
---
|
||||
|
||||
# ROS 2 集成开发规范
|
||||
|
||||
## 概述
|
||||
|
||||
Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。
|
||||
|
||||
## 核心组件
|
||||
|
||||
### BaseROS2DeviceNode
|
||||
|
||||
设备节点基类,提供:
|
||||
- ROS Topic 自动发布(状态属性)
|
||||
- Action Server 自动创建(设备动作)
|
||||
- 资源管理服务
|
||||
- 异步任务调度
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
```
|
||||
|
||||
### 消息转换器
|
||||
|
||||
```python
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
convert_to_ros_msg,
|
||||
convert_from_ros_msg_with_mapping,
|
||||
msg_converter_manager,
|
||||
ros_action_to_json_schema,
|
||||
ros_message_to_json_schema,
|
||||
)
|
||||
```
|
||||
|
||||
## 设备与 ROS 集成
|
||||
|
||||
### post_init 方法
|
||||
|
||||
设备类必须实现 `post_init` 方法接收 ROS 节点:
|
||||
|
||||
```python
|
||||
class MyDevice:
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
"""ROS节点注入"""
|
||||
self._ros_node = ros_node
|
||||
```
|
||||
|
||||
### 状态属性发布
|
||||
|
||||
设备的 `@property` 属性会自动发布为 ROS Topic:
|
||||
|
||||
```python
|
||||
class MyDevice:
|
||||
@property
|
||||
def temperature(self) -> float:
|
||||
return self._temperature
|
||||
|
||||
# 自动发布到 /{namespace}/temperature Topic
|
||||
```
|
||||
|
||||
### Topic 配置装饰器
|
||||
|
||||
```python
|
||||
from unilabos.utils.decorator import topic_config
|
||||
|
||||
class MyDevice:
|
||||
@property
|
||||
@topic_config(period=1.0, print_publish=False, qos=10)
|
||||
def fast_data(self) -> float:
|
||||
"""高频数据 - 每秒发布一次"""
|
||||
return self._fast_data
|
||||
|
||||
@property
|
||||
@topic_config(period=5.0)
|
||||
def slow_data(self) -> str:
|
||||
"""低频数据 - 每5秒发布一次"""
|
||||
return self._slow_data
|
||||
```
|
||||
|
||||
### 订阅装饰器
|
||||
|
||||
```python
|
||||
from unilabos.utils.decorator import subscribe
|
||||
|
||||
class MyDevice:
|
||||
@subscribe(topic="/external/sensor_data", qos=10)
|
||||
def on_sensor_data(self, msg):
|
||||
"""订阅外部Topic"""
|
||||
self._sensor_value = msg.data
|
||||
```
|
||||
|
||||
## 异步操作
|
||||
|
||||
### 使用 ROS 节点睡眠
|
||||
|
||||
```python
|
||||
# 推荐:使用ROS节点的睡眠方法
|
||||
await self._ros_node.sleep(1.0)
|
||||
|
||||
# 不推荐:直接使用asyncio(可能导致回调阻塞)
|
||||
await asyncio.sleep(1.0)
|
||||
```
|
||||
|
||||
### 获取事件循环
|
||||
|
||||
```python
|
||||
from unilabos.ros.x.rclpyx import get_event_loop
|
||||
|
||||
loop = get_event_loop()
|
||||
```
|
||||
|
||||
## 消息类型
|
||||
|
||||
### unilabos_msgs 包
|
||||
|
||||
```python
|
||||
from unilabos_msgs.msg import Resource
|
||||
from unilabos_msgs.srv import (
|
||||
ResourceAdd,
|
||||
ResourceDelete,
|
||||
ResourceUpdate,
|
||||
ResourceList,
|
||||
SerialCommand,
|
||||
)
|
||||
from unilabos_msgs.action import SendCmd
|
||||
```
|
||||
|
||||
### Resource 消息结构
|
||||
|
||||
```python
|
||||
Resource:
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
type: str
|
||||
parent: str
|
||||
children: List[str]
|
||||
config: str # JSON字符串
|
||||
data: str # JSON字符串
|
||||
sample_id: str
|
||||
pose: Pose
|
||||
```
|
||||
|
||||
## 日志适配器
|
||||
|
||||
```python
|
||||
from unilabos.utils.log import info, debug, warning, error, trace
|
||||
|
||||
class MyDevice:
|
||||
def __init__(self):
|
||||
# 创建设备专属日志器
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
```
|
||||
|
||||
ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。
|
||||
|
||||
## Action Server
|
||||
|
||||
设备动作自动创建为 ROS Action Server:
|
||||
|
||||
```yaml
|
||||
# 在注册表中配置
|
||||
action_value_mappings:
|
||||
my_action:
|
||||
type: UniLabJsonCommandAsync # 异步Action
|
||||
goal: {...}
|
||||
feedback: {...}
|
||||
result: {...}
|
||||
```
|
||||
|
||||
### Action 类型
|
||||
|
||||
- **UniLabJsonCommand**: 同步动作
|
||||
- **UniLabJsonCommandAsync**: 异步动作(支持feedback)
|
||||
|
||||
## 服务客户端
|
||||
|
||||
```python
|
||||
from rclpy.client import Client
|
||||
|
||||
# 调用其他节点的服务
|
||||
response = await self._ros_node.call_service(
|
||||
service_name="/other_node/service",
|
||||
request=MyServiceRequest(...)
|
||||
)
|
||||
```
|
||||
|
||||
## 命名空间
|
||||
|
||||
设备节点使用命名空间隔离:
|
||||
|
||||
```
|
||||
/{device_id}/ # 设备命名空间
|
||||
/{device_id}/status # 状态Topic
|
||||
/{device_id}/temperature # 温度Topic
|
||||
/{device_id}/my_action # 动作Server
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
### 查看 Topic
|
||||
|
||||
```bash
|
||||
ros2 topic list
|
||||
ros2 topic echo /{device_id}/status
|
||||
```
|
||||
|
||||
### 查看 Action
|
||||
|
||||
```bash
|
||||
ros2 action list
|
||||
ros2 action info /{device_id}/my_action
|
||||
```
|
||||
|
||||
### 查看 Service
|
||||
|
||||
```bash
|
||||
ros2 service list
|
||||
ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **状态属性命名**: 使用蛇形命名法(snake_case)
|
||||
2. **Topic 频率**: 根据数据变化频率调整,避免过高频率
|
||||
3. **Action 反馈**: 长时间操作提供进度反馈
|
||||
4. **错误处理**: 使用 try-except 捕获并记录错误
|
||||
5. **资源清理**: 在 cleanup 方法中正确清理资源
|
||||
357
.cursor/rules/testing-patterns.mdc
Normal file
357
.cursor/rules/testing-patterns.mdc
Normal file
@@ -0,0 +1,357 @@
|
||||
---
|
||||
description: 测试开发规范
|
||||
globs: ["tests/**/*.py", "**/test_*.py"]
|
||||
---
|
||||
|
||||
# 测试开发规范
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── __init__.py
|
||||
├── devices/ # 设备测试
|
||||
│ └── liquid_handling/
|
||||
│ └── test_transfer_liquid.py
|
||||
├── resources/ # 资源测试
|
||||
│ ├── test_bottle_carrier.py
|
||||
│ └── test_resourcetreeset.py
|
||||
├── ros/ # ROS消息测试
|
||||
│ └── msgs/
|
||||
│ ├── test_basic.py
|
||||
│ ├── test_conversion.py
|
||||
│ └── test_mapping.py
|
||||
└── workflow/ # 工作流测试
|
||||
└── merge_workflow.py
|
||||
```
|
||||
|
||||
## 测试框架
|
||||
|
||||
使用 pytest 作为测试框架:
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pytest tests/
|
||||
|
||||
# 运行特定测试文件
|
||||
pytest tests/resources/test_bottle_carrier.py
|
||||
|
||||
# 运行特定测试函数
|
||||
pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier
|
||||
|
||||
# 显示详细输出
|
||||
pytest -v tests/
|
||||
|
||||
# 显示打印输出
|
||||
pytest -s tests/
|
||||
```
|
||||
|
||||
## 测试文件模板
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# 导入被测试的模块
|
||||
from unilabos.resources.bioyond.bottle_carriers import (
|
||||
BIOYOND_Electrolyte_6VialCarrier,
|
||||
)
|
||||
from unilabos.resources.bioyond.bottles import (
|
||||
BIOYOND_PolymerStation_Solid_Vial,
|
||||
)
|
||||
|
||||
|
||||
class TestBottleCarrier:
|
||||
"""BottleCarrier 测试类"""
|
||||
|
||||
def setup_method(self):
|
||||
"""每个测试方法前执行"""
|
||||
self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier")
|
||||
|
||||
def teardown_method(self):
|
||||
"""每个测试方法后执行"""
|
||||
pass
|
||||
|
||||
def test_carrier_creation(self):
|
||||
"""测试载架创建"""
|
||||
assert self.carrier.name == "test_carrier"
|
||||
assert len(self.carrier.sites) == 6
|
||||
|
||||
def test_bottle_placement(self):
|
||||
"""测试瓶子放置"""
|
||||
bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle")
|
||||
# 测试逻辑...
|
||||
assert bottle.name == "test_bottle"
|
||||
|
||||
|
||||
def test_standalone_function():
|
||||
"""独立测试函数"""
|
||||
result = some_function()
|
||||
assert result is True
|
||||
|
||||
|
||||
# 参数化测试
|
||||
@pytest.mark.parametrize("input,expected", [
|
||||
("5 min", 300.0),
|
||||
("1 h", 3600.0),
|
||||
("120", 120.0),
|
||||
(60, 60.0),
|
||||
])
|
||||
def test_time_parsing(input, expected):
|
||||
"""测试时间解析"""
|
||||
from unilabos.compile.utils.unit_parser import parse_time_input
|
||||
assert parse_time_input(input) == expected
|
||||
|
||||
|
||||
# 异常测试
|
||||
def test_invalid_input_raises_error():
|
||||
"""测试无效输入抛出异常"""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
invalid_function("bad_input")
|
||||
assert "invalid" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
# 跳过条件测试
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("ROS_DISTRO"),
|
||||
reason="需要ROS环境"
|
||||
)
|
||||
def test_ros_feature():
|
||||
"""需要ROS环境的测试"""
|
||||
pass
|
||||
```
|
||||
|
||||
## 设备测试
|
||||
|
||||
### 虚拟设备测试
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer
|
||||
|
||||
|
||||
class TestVirtualStirrer:
|
||||
"""VirtualStirrer 测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def stirrer(self):
|
||||
"""创建测试用搅拌器"""
|
||||
device = VirtualStirrer(
|
||||
device_id="test_stirrer",
|
||||
config={"max_speed": 1500.0, "min_speed": 50.0}
|
||||
)
|
||||
|
||||
# Mock ROS节点
|
||||
mock_node = MagicMock()
|
||||
mock_node.sleep = AsyncMock(return_value=None)
|
||||
device.post_init(mock_node)
|
||||
|
||||
return device
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, stirrer):
|
||||
"""测试初始化"""
|
||||
result = await stirrer.initialize()
|
||||
assert result is True
|
||||
assert stirrer.status == "待机中"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stir_action(self, stirrer):
|
||||
"""测试搅拌动作"""
|
||||
await stirrer.initialize()
|
||||
|
||||
result = await stirrer.stir(
|
||||
stir_time=5.0,
|
||||
stir_speed=300.0,
|
||||
settling_time=2.0
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert stirrer.operation_mode == "Completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stir_invalid_speed(self, stirrer):
|
||||
"""测试无效速度"""
|
||||
await stirrer.initialize()
|
||||
|
||||
# 速度超出范围
|
||||
result = await stirrer.stir(
|
||||
stir_time=5.0,
|
||||
stir_speed=2000.0, # 超过max_speed
|
||||
settling_time=0.0
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert "错误" in stirrer.status
|
||||
```
|
||||
|
||||
### 异步测试配置
|
||||
|
||||
```python
|
||||
# conftest.py
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""创建事件循环"""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
```
|
||||
|
||||
## 资源测试
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
)
|
||||
|
||||
|
||||
def test_resource_tree_creation():
|
||||
"""测试资源树创建"""
|
||||
tree_set = ResourceTreeSet()
|
||||
|
||||
# 添加资源
|
||||
resource = {"id": "res_1", "name": "Resource 1"}
|
||||
tree_set.add_resource(resource)
|
||||
|
||||
# 验证
|
||||
assert len(tree_set.all_nodes) == 1
|
||||
assert tree_set.get_resource("res_1") is not None
|
||||
|
||||
|
||||
def test_resource_tree_merge():
|
||||
"""测试资源树合并"""
|
||||
local_set = ResourceTreeSet()
|
||||
remote_set = ResourceTreeSet()
|
||||
|
||||
# 设置数据...
|
||||
|
||||
local_set.merge_remote_resources(remote_set)
|
||||
|
||||
# 验证合并结果...
|
||||
```
|
||||
|
||||
## ROS 消息测试
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
convert_to_ros_msg,
|
||||
convert_from_ros_msg_with_mapping,
|
||||
msg_converter_manager,
|
||||
)
|
||||
|
||||
|
||||
def test_message_conversion():
|
||||
"""测试消息转换"""
|
||||
# Python -> ROS
|
||||
python_data = {"id": "test", "value": 42}
|
||||
ros_msg = convert_to_ros_msg(python_data, MyMsgType)
|
||||
|
||||
assert ros_msg.id == "test"
|
||||
assert ros_msg.value == 42
|
||||
|
||||
# ROS -> Python
|
||||
result = convert_from_ros_msg_with_mapping(ros_msg, mapping)
|
||||
assert result["id"] == "test"
|
||||
```
|
||||
|
||||
## 协议测试
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import networkx as nx
|
||||
from unilabos.compile.stir_protocol import (
|
||||
generate_stir_protocol,
|
||||
extract_vessel_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def topology_graph():
|
||||
"""创建测试拓扑图"""
|
||||
G = nx.DiGraph()
|
||||
G.add_node("flask_1", **{"class": "flask"})
|
||||
G.add_node("stirrer_1", **{"class": "virtual_stirrer"})
|
||||
G.add_edge("stirrer_1", "flask_1")
|
||||
return G
|
||||
|
||||
|
||||
def test_generate_stir_protocol(topology_graph):
|
||||
"""测试搅拌协议生成"""
|
||||
actions = generate_stir_protocol(
|
||||
G=topology_graph,
|
||||
vessel="flask_1",
|
||||
time="5 min",
|
||||
stir_speed=300.0
|
||||
)
|
||||
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["device_id"] == "stirrer_1"
|
||||
assert actions[0]["action_name"] == "stir"
|
||||
|
||||
|
||||
def test_extract_vessel_id():
|
||||
"""测试vessel_id提取"""
|
||||
# 字典格式
|
||||
assert extract_vessel_id({"id": "flask_1"}) == "flask_1"
|
||||
|
||||
# 字符串格式
|
||||
assert extract_vessel_id("flask_2") == "flask_2"
|
||||
|
||||
# 空值
|
||||
assert extract_vessel_id("") == ""
|
||||
```
|
||||
|
||||
## 测试标记
|
||||
|
||||
```python
|
||||
# 慢速测试
|
||||
@pytest.mark.slow
|
||||
def test_long_running():
|
||||
pass
|
||||
|
||||
# 需要网络
|
||||
@pytest.mark.network
|
||||
def test_network_call():
|
||||
pass
|
||||
|
||||
# 需要ROS
|
||||
@pytest.mark.ros
|
||||
def test_ros_feature():
|
||||
pass
|
||||
```
|
||||
|
||||
运行特定标记的测试:
|
||||
|
||||
```bash
|
||||
pytest -m "not slow" # 排除慢速测试
|
||||
pytest -m ros # 仅ROS测试
|
||||
```
|
||||
|
||||
## 覆盖率
|
||||
|
||||
```bash
|
||||
# 生成覆盖率报告
|
||||
pytest --cov=unilabos tests/
|
||||
|
||||
# HTML报告
|
||||
pytest --cov=unilabos --cov-report=html tests/
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **测试命名**: `test_{功能}_{场景}_{预期结果}`
|
||||
2. **独立性**: 每个测试独立运行,不依赖其他测试
|
||||
3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务
|
||||
4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码
|
||||
5. **fixtures**: 使用 fixtures 共享测试设置
|
||||
6. **断言清晰**: 每个断言只验证一件事
|
||||
353
.cursor/rules/unilabos-project.mdc
Normal file
353
.cursor/rules/unilabos-project.mdc
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则
|
||||
globs: ["**/*.py", "**/*.yaml", "**/*.json"]
|
||||
---
|
||||
|
||||
# Uni-Lab-OS 项目开发规范
|
||||
|
||||
## 项目概述
|
||||
|
||||
Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Python 3.11** - 核心开发语言
|
||||
- **ROS 2** - 设备通信中间件 (rclpy)
|
||||
- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge)
|
||||
- **FastAPI** - Web API 服务
|
||||
- **WebSocket** - 实时通信
|
||||
- **NetworkX** - 拓扑图管理
|
||||
- **YAML** - 配置和注册表定义
|
||||
- **PyLabRobot** - 实验室自动化库集成
|
||||
- **pytest** - 测试框架
|
||||
- **asyncio** - 异步编程
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
unilabos/
|
||||
├── app/ # 应用入口、Web服务、后端
|
||||
├── compile/ # 协议编译器 (stir, add, filter 等)
|
||||
├── config/ # 配置管理
|
||||
├── devices/ # 设备驱动 (真实/虚拟)
|
||||
├── device_comms/ # 设备通信协议
|
||||
├── device_mesh/ # 3D网格和可视化
|
||||
├── registry/ # 设备和资源类型注册表 (YAML)
|
||||
├── resources/ # 资源定义
|
||||
├── ros/ # ROS 2 集成
|
||||
├── utils/ # 工具函数
|
||||
└── workflow/ # 工作流管理
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
### Python 风格
|
||||
|
||||
1. **类型注解**:所有函数必须使用类型注解
|
||||
```python
|
||||
def transfer_liquid(
|
||||
source: str,
|
||||
destination: str,
|
||||
volume: float,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
```
|
||||
|
||||
2. **Docstring**:使用 Google 风格的文档字符串
|
||||
```python
|
||||
def initialize(self) -> bool:
|
||||
"""
|
||||
初始化设备
|
||||
|
||||
Returns:
|
||||
bool: 初始化是否成功
|
||||
"""
|
||||
```
|
||||
|
||||
3. **导入顺序**:
|
||||
- 标准库
|
||||
- 第三方库
|
||||
- ROS 相关 (rclpy, unilabos_msgs)
|
||||
- 项目内部模块
|
||||
|
||||
### 异步编程
|
||||
|
||||
1. 设备操作方法使用 `async def`
|
||||
2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()`
|
||||
3. 长时间运行操作需提供进度反馈
|
||||
|
||||
```python
|
||||
async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool:
|
||||
"""执行搅拌操作"""
|
||||
start_time = time_module.time()
|
||||
while True:
|
||||
elapsed = time_module.time() - start_time
|
||||
remaining = max(0, stir_time - elapsed)
|
||||
|
||||
self.data.update({
|
||||
"remaining_time": remaining,
|
||||
"status": f"搅拌中: {stir_speed} RPM"
|
||||
})
|
||||
|
||||
if remaining <= 0:
|
||||
break
|
||||
await self._ros_node.sleep(1.0)
|
||||
return True
|
||||
```
|
||||
|
||||
### 日志规范
|
||||
|
||||
使用项目自定义日志系统:
|
||||
|
||||
```python
|
||||
from unilabos.utils.log import logger, info, debug, warning, error, trace
|
||||
|
||||
# 在设备类中使用
|
||||
self.logger = logging.getLogger(f"DeviceName.{self.device_id}")
|
||||
self.logger.info("设备初始化完成")
|
||||
```
|
||||
|
||||
## 设备驱动开发
|
||||
|
||||
### 设备类结构
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
class MyDevice:
|
||||
"""设备驱动类"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
|
||||
self.device_id = device_id or "unknown_device"
|
||||
self.config = config or {}
|
||||
self.data = {} # 设备状态数据
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
"""ROS节点注入"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""初始化设备"""
|
||||
pass
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
"""清理设备"""
|
||||
pass
|
||||
|
||||
# 状态属性 - 自动发布为 ROS Topic
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "待机")
|
||||
```
|
||||
|
||||
### 状态属性装饰器
|
||||
|
||||
```python
|
||||
from unilabos.utils.decorator import topic_config
|
||||
|
||||
class MyDevice:
|
||||
@property
|
||||
@topic_config(period=1.0, qos=10) # 每秒发布一次
|
||||
def temperature(self) -> float:
|
||||
return self._temperature
|
||||
```
|
||||
|
||||
### 虚拟设备
|
||||
|
||||
虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py`
|
||||
|
||||
## 注册表配置
|
||||
|
||||
### 设备注册表 (YAML)
|
||||
|
||||
位置: `unilabos/registry/devices/*.yaml`
|
||||
|
||||
```yaml
|
||||
my_device_type:
|
||||
category:
|
||||
- my_category
|
||||
description: "设备描述"
|
||||
version: "1.0.0"
|
||||
class:
|
||||
module: "unilabos.devices.my_device:MyDevice"
|
||||
type: python
|
||||
status_types:
|
||||
status: String
|
||||
temperature: Float64
|
||||
action_value_mappings:
|
||||
auto-initialize:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
feedback: {}
|
||||
result: {}
|
||||
schema: {...}
|
||||
```
|
||||
|
||||
### 资源注册表 (YAML)
|
||||
|
||||
位置: `unilabos/registry/resources/**/*.yaml`
|
||||
|
||||
```yaml
|
||||
my_container:
|
||||
category:
|
||||
- container
|
||||
class:
|
||||
module: "unilabos.resources.my_resource:MyContainer"
|
||||
type: pylabrobot
|
||||
version: "1.0.0"
|
||||
```
|
||||
|
||||
## 协议编译器
|
||||
|
||||
位置: `unilabos/compile/*_protocol.py`
|
||||
|
||||
### 协议生成函数模板
|
||||
|
||||
```python
|
||||
from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
|
||||
def generate_my_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: Union[str, dict],
|
||||
param1: float = 0.0,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成操作协议序列
|
||||
|
||||
Args:
|
||||
G: 物理拓扑图
|
||||
vessel: 容器ID或字典
|
||||
param1: 参数1
|
||||
|
||||
Returns:
|
||||
List[Dict]: 动作序列
|
||||
"""
|
||||
# 提取vessel_id
|
||||
vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "")
|
||||
|
||||
# 查找设备
|
||||
device_id = find_connected_device(G, vessel_id)
|
||||
|
||||
# 生成动作
|
||||
action_sequence = [{
|
||||
"device_id": device_id,
|
||||
"action_name": "my_action",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id},
|
||||
"param1": float(param1)
|
||||
}
|
||||
}]
|
||||
|
||||
return action_sequence
|
||||
```
|
||||
|
||||
## 测试规范
|
||||
|
||||
### 测试文件位置
|
||||
|
||||
- 单元测试: `tests/` 目录
|
||||
- 设备测试: `tests/devices/`
|
||||
- 资源测试: `tests/resources/`
|
||||
- ROS消息测试: `tests/ros/msgs/`
|
||||
|
||||
### 测试命名
|
||||
|
||||
```python
|
||||
# tests/devices/my_device/test_my_device.py
|
||||
|
||||
import pytest
|
||||
|
||||
def test_device_initialization():
|
||||
"""测试设备初始化"""
|
||||
pass
|
||||
|
||||
def test_device_action():
|
||||
"""测试设备动作"""
|
||||
pass
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
```python
|
||||
from unilabos.utils.exception import UniLabException
|
||||
|
||||
try:
|
||||
result = await device.execute_action()
|
||||
except ValueError as e:
|
||||
self.logger.error(f"参数错误: {e}")
|
||||
self.data["status"] = "错误: 参数无效"
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"执行失败: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
## 配置管理
|
||||
|
||||
```python
|
||||
from unilabos.config.config import BasicConfig, HTTPConfig
|
||||
|
||||
# 读取配置
|
||||
port = BasicConfig.port
|
||||
is_host = BasicConfig.is_host_mode
|
||||
|
||||
# 配置文件: local_config.py
|
||||
```
|
||||
|
||||
## 常用工具
|
||||
|
||||
### 单例模式
|
||||
|
||||
```python
|
||||
from unilabos.utils.decorator import singleton
|
||||
|
||||
@singleton
|
||||
class MyManager:
|
||||
pass
|
||||
```
|
||||
|
||||
### 类型检查
|
||||
|
||||
```python
|
||||
from unilabos.utils.type_check import NoAliasDumper
|
||||
|
||||
yaml.dump(data, f, Dumper=NoAliasDumper)
|
||||
```
|
||||
|
||||
### 导入管理
|
||||
|
||||
```python
|
||||
from unilabos.utils.import_manager import get_class
|
||||
|
||||
device_class = get_class("unilabos.devices.my_device:MyDevice")
|
||||
```
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
提交信息格式:
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
```
|
||||
|
||||
类型:
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复bug
|
||||
- `docs`: 文档更新
|
||||
- `refactor`: 重构
|
||||
- `test`: 测试相关
|
||||
- `chore`: 构建/工具相关
|
||||
|
||||
示例:
|
||||
```
|
||||
feat(devices): 添加虚拟搅拌器设备
|
||||
|
||||
- 实现VirtualStirrer类
|
||||
- 支持定时搅拌和持续搅拌模式
|
||||
- 添加速度验证逻辑
|
||||
```
|
||||
24
.cursor/skills/add-device/SKILL.md
Normal file
24
.cursor/skills/add-device/SKILL.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: add-device
|
||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Walks through device category selection (thing model), communication protocol, command protocol collection, driver creation, registry YAML, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, configure device registry, or mentions 接入设备/添加设备/设备驱动/物模型.
|
||||
---
|
||||
|
||||
# 添加新设备到 Uni-Lab-OS
|
||||
|
||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南并严格遵循。
|
||||
|
||||
该指南包含:
|
||||
- 8 步完整流程(设备类别、通信协议、指令收集、接口对齐、驱动创建、注册表、图文件、验证)
|
||||
- 所有物模型代码模板(注射泵、电磁阀、蠕动泵、温控、电机等)
|
||||
- 通信协议代码片段(Serial、Modbus、TCP、HTTP、OPC UA)
|
||||
- 现有设备接口快照(用于第四步对齐,包含参数名、status_types、方法签名)
|
||||
- 常见错误检查清单
|
||||
|
||||
**Cursor 工具映射:**
|
||||
|
||||
| 指南中的操作 | Cursor 中使用的工具 |
|
||||
|---|---|
|
||||
| 向用户确认设备类别、协议等信息 | 使用 AskQuestion 工具 |
|
||||
| 搜索已有设备注册表 | 使用 Grep 在 `unilabos/registry/devices/` 中搜索 |
|
||||
| 读取用户提供的协议文档/SDK 代码 | 使用 Read 工具 |
|
||||
| 第四步对齐:查找同类设备接口 | 优先使用 Grep 搜索仓库中的最新注册表;指南中的「现有设备接口快照」作为兜底参考 |
|
||||
323
.cursor/skills/add-protocol/SKILL.md
Normal file
323
.cursor/skills/add-protocol/SKILL.md
Normal file
@@ -0,0 +1,323 @@
|
||||
---
|
||||
name: add-protocol
|
||||
description: Guide for adding new experiment protocols to Uni-Lab-OS (添加新实验操作协议). Walks through ROS Action definition, Pydantic model creation, protocol generator implementation, and registration. Use when the user wants to add a new protocol, create a compile function, implement an experiment operation, or mentions 协议/protocol/编译/compile/实验操作.
|
||||
---
|
||||
|
||||
# 添加新实验操作协议(Protocol)
|
||||
|
||||
Protocol 是对实验有意义的完整动作(如泵转移、过滤、溶解),需要多设备协同。`compile/` 中的生成函数根据设备连接图将抽象操作"编译"为设备指令序列。
|
||||
|
||||
添加一个 Protocol 需修改 **6 个文件**,按以下流程执行。
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认协议信息
|
||||
|
||||
向用户确认:
|
||||
|
||||
| 信息 | 示例 |
|
||||
|------|------|
|
||||
| 协议英文名 | `MyNewProtocol` |
|
||||
| 操作描述 | 将固体样品研磨至目标粒径 |
|
||||
| Goal 参数(必需 + 可选) | `vessel: dict`, `time: float = 300.0` |
|
||||
| Result 字段 | `success: bool`, `message: str` |
|
||||
| 需要哪些设备协同 | 研磨器、搅拌器 |
|
||||
|
||||
---
|
||||
|
||||
## 第二步:创建 ROS Action 定义
|
||||
|
||||
路径:`unilabos_msgs/action/<ActionName>.action`
|
||||
|
||||
三段式结构(Goal / Result / Feedback),用 `---` 分隔:
|
||||
|
||||
```
|
||||
# Goal
|
||||
Resource vessel
|
||||
float64 time
|
||||
string mode
|
||||
---
|
||||
# Result
|
||||
bool success
|
||||
string return_info
|
||||
---
|
||||
# Feedback
|
||||
string status
|
||||
string current_device
|
||||
builtin_interfaces/Duration time_spent
|
||||
builtin_interfaces/Duration time_remaining
|
||||
```
|
||||
|
||||
**类型映射:**
|
||||
|
||||
| Python 类型 | ROS 类型 | 说明 |
|
||||
|------------|----------|------|
|
||||
| `dict` | `Resource` | 容器/设备引用,自定义消息类型 |
|
||||
| `float` | `float64` | |
|
||||
| `int` | `int32` | |
|
||||
| `str` | `string` | |
|
||||
| `bool` | `bool` | |
|
||||
|
||||
> `Resource` 是 `unilabos_msgs/msg/Resource.msg` 中定义的自定义消息类型。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:注册 Action 到 CMakeLists
|
||||
|
||||
在 `unilabos_msgs/CMakeLists.txt` 的 `set(action_files ...)` 块中添加:
|
||||
|
||||
```cmake
|
||||
"action/MyNewAction.action"
|
||||
```
|
||||
|
||||
> 调试时需编译:`cd unilabos_msgs && colcon build && source ./install/local_setup.sh && cd ..`
|
||||
> PR 合并后 CI/CD 自动发布,`mamba update ros-humble-unilabos-msgs` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 第四步:创建 Pydantic 模型
|
||||
|
||||
在 `unilabos/messages/__init__.py` 中添加(位于 `# Start Protocols` 和 `# End Protocols` 之间):
|
||||
|
||||
```python
|
||||
class MyNewProtocol(BaseModel):
|
||||
# === 必需参数 ===
|
||||
vessel: dict = Field(..., description="目标容器")
|
||||
|
||||
# === 可选参数 ===
|
||||
time: float = Field(300.0, description="操作时间 (秒)")
|
||||
mode: str = Field("default", description="操作模式")
|
||||
|
||||
def model_post_init(self, __context):
|
||||
"""参数验证和修正"""
|
||||
if self.time <= 0:
|
||||
self.time = 300.0
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- 参数名必须与 `.action` 文件中 Goal 字段完全一致
|
||||
- `dict` 类型对应 `.action` 中的 `Resource`
|
||||
- 将类名加入文件末尾的 `__all__` 列表
|
||||
|
||||
---
|
||||
|
||||
## 第五步:实现协议生成函数
|
||||
|
||||
路径:`unilabos/compile/<protocol_name>_protocol.py`
|
||||
|
||||
```python
|
||||
import networkx as nx
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def generate_my_new_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
time: float = 300.0,
|
||||
mode: str = "default",
|
||||
**kwargs,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将 MyNewProtocol 编译为设备动作序列。
|
||||
|
||||
Args:
|
||||
G: 设备连接图(NetworkX),节点为设备/容器,边为物理连接
|
||||
vessel: 目标容器 {"id": "reactor_1"}
|
||||
time: 操作时间(秒)
|
||||
mode: 操作模式
|
||||
|
||||
Returns:
|
||||
动作列表,每个元素为:
|
||||
- dict: 单步动作
|
||||
- list[dict]: 并行动作
|
||||
"""
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
actions = []
|
||||
|
||||
# 查找相关设备(通过图的连接关系)
|
||||
# 生成动作序列
|
||||
actions.append({
|
||||
"device_id": "target_device_id",
|
||||
"action_name": "some_action",
|
||||
"action_kwargs": {"param": "value"}
|
||||
})
|
||||
|
||||
# 等待
|
||||
actions.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": time}
|
||||
})
|
||||
|
||||
return actions
|
||||
```
|
||||
|
||||
### 动作字典格式
|
||||
|
||||
```python
|
||||
# 单步动作(发给子设备)
|
||||
{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}}
|
||||
|
||||
# 发给工作站自身
|
||||
{"device_id": "self", "action_name": "my_action", "action_kwargs": {...}}
|
||||
|
||||
# 等待
|
||||
{"action_name": "wait", "action_kwargs": {"time": 5.0}}
|
||||
|
||||
# 并行动作(列表嵌套)
|
||||
[
|
||||
{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}},
|
||||
{"device_id": "stirrer_1", "action_name": "start_stir", "action_kwargs": {"stir_speed": 300}}
|
||||
]
|
||||
```
|
||||
|
||||
### 关于 `vessel` 参数类型
|
||||
|
||||
现有协议的 `vessel` 参数类型不统一:
|
||||
- 新协议趋势:使用 `dict`(如 `{"id": "reactor_1"}`)
|
||||
- 旧协议:使用 `str`(如 `"reactor_1"`)
|
||||
- 兼容写法:`Union[str, dict]`
|
||||
|
||||
**建议新协议统一使用 `dict` 类型**,通过 `get_vessel()` 兼容两种输入。
|
||||
|
||||
### 公共工具函数(`unilabos/compile/utils/`)
|
||||
|
||||
| 函数 | 用途 |
|
||||
|------|------|
|
||||
| `get_vessel(vessel)` | 解析容器参数为 `(vessel_id, vessel_data)`,兼容 dict 和 str |
|
||||
| `find_solvent_vessel(G, solvent)` | 根据溶剂名查找容器(精确→命名规则→模糊→液体类型) |
|
||||
| `find_reagent_vessel(G, reagent)` | 根据试剂名查找容器(支持固体和液体) |
|
||||
| `find_connected_stirrer(G, vessel)` | 查找与容器相连的搅拌器 |
|
||||
| `find_solid_dispenser(G)` | 查找固体加样器 |
|
||||
|
||||
### 协议内专属查找函数
|
||||
|
||||
许多协议在自己的文件内定义了专属的 `find_*` 函数(不在 `utils/` 中)。编写新协议时,优先复用 `utils/` 中的公共函数;如需特殊查找逻辑,在协议文件内部定义即可:
|
||||
|
||||
```python
|
||||
def find_my_special_device(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与容器相关的特殊设备"""
|
||||
for node in G.nodes():
|
||||
if 'my_device_type' in G.nodes[node].get('class', '').lower():
|
||||
return node
|
||||
raise ValueError("未找到特殊设备")
|
||||
```
|
||||
|
||||
### 复用已有协议
|
||||
|
||||
复杂协议通常组合已有协议:
|
||||
|
||||
```python
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
actions.extend(generate_pump_protocol_with_rinsing(
|
||||
G, from_vessel=solvent_vessel, to_vessel=vessel, volume=volume
|
||||
))
|
||||
```
|
||||
|
||||
### 图查询模式
|
||||
|
||||
```python
|
||||
# 查找与容器相连的特定类型设备
|
||||
for neighbor in G.neighbors(vessel_id):
|
||||
node_data = G.nodes[neighbor]
|
||||
if "heater" in node_data.get("class", ""):
|
||||
heater_id = neighbor
|
||||
break
|
||||
|
||||
# 查找最短路径(泵转移)
|
||||
path = nx.shortest_path(G, source=from_vessel_id, target=to_vessel_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:注册协议生成函数
|
||||
|
||||
在 `unilabos/compile/__init__.py` 中:
|
||||
|
||||
1. 顶部添加导入:
|
||||
|
||||
```python
|
||||
from .my_new_protocol import generate_my_new_protocol
|
||||
```
|
||||
|
||||
2. 在 `action_protocol_generators` 字典中添加映射:
|
||||
|
||||
```python
|
||||
action_protocol_generators = {
|
||||
# ... 已有协议
|
||||
MyNewProtocol: generate_my_new_protocol,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第七步:配置图文件
|
||||
|
||||
在工作站的图文件中,将协议名加入 `protocol_type`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"class": "workstation",
|
||||
"config": {
|
||||
"protocol_type": ["PumpTransferProtocol", "MyNewProtocol"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第八步:验证
|
||||
|
||||
```bash
|
||||
# 1. 模块可导入
|
||||
python -c "from unilabos.messages import MyNewProtocol; print(MyNewProtocol.model_fields)"
|
||||
|
||||
# 2. 生成函数可导入
|
||||
python -c "from unilabos.compile import action_protocol_generators; print(list(action_protocol_generators.keys()))"
|
||||
|
||||
# 3. 启动测试(可选)
|
||||
unilab -g <graph>.json --complete_registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
协议接入进度:
|
||||
- [ ] 1. 确认协议名、参数、涉及设备
|
||||
- [ ] 2. 创建 .action 文件 (unilabos_msgs/action/<Name>.action)
|
||||
- [ ] 3. 注册到 CMakeLists.txt
|
||||
- [ ] 4. 创建 Pydantic 模型 (unilabos/messages/__init__.py) + 更新 __all__
|
||||
- [ ] 5. 实现生成函数 (unilabos/compile/<name>_protocol.py)
|
||||
- [ ] 6. 注册到 compile/__init__.py
|
||||
- [ ] 7. 配置图文件 protocol_type
|
||||
- [ ] 8. 验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现复杂协议时,详见 [reference.md](reference.md):协议运行时数据流、mock graph 测试模式、单位解析工具(`unit_parser.py`)、复杂协议组合模式(以 dissolve 为例)。
|
||||
|
||||
---
|
||||
|
||||
## 现有协议速查
|
||||
|
||||
| 协议 | Pydantic 类 | 生成函数 | 核心参数 |
|
||||
|------|-------------|---------|---------|
|
||||
| 泵转移 | `PumpTransferProtocol` | `generate_pump_protocol_with_rinsing` | `from_vessel, to_vessel, volume` |
|
||||
| 简单转移 | `TransferProtocol` | `generate_pump_protocol` | `from_vessel, to_vessel, volume` |
|
||||
| 加样 | `AddProtocol` | `generate_add_protocol` | `vessel, reagent, volume` |
|
||||
| 过滤 | `FilterProtocol` | `generate_filter_protocol` | `vessel, filtrate_vessel` |
|
||||
| 溶解 | `DissolveProtocol` | `generate_dissolve_protocol` | `vessel, solvent, volume` |
|
||||
| 加热/冷却 | `HeatChillProtocol` | `generate_heat_chill_protocol` | `vessel, temp, time` |
|
||||
| 搅拌 | `StirProtocol` | `generate_stir_protocol` | `vessel, time` |
|
||||
| 分离 | `SeparateProtocol` | `generate_separate_protocol` | `from_vessel, separation_vessel, solvent` |
|
||||
| 蒸发 | `EvaporateProtocol` | `generate_evaporate_protocol` | `vessel, pressure, temp, time` |
|
||||
| 清洗 | `CleanProtocol` | `generate_clean_protocol` | `vessel, solvent, volume` |
|
||||
| 离心 | `CentrifugeProtocol` | `generate_centrifuge_protocol` | `vessel, speed, time` |
|
||||
| 抽气充气 | `EvacuateAndRefillProtocol` | `generate_evacuateandrefill_protocol` | `vessel, gas` |
|
||||
207
.cursor/skills/add-protocol/reference.md
Normal file
207
.cursor/skills/add-protocol/reference.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# 协议高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含协议运行时数据流、测试模式、单位解析工具和复杂协议组合模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 协议运行时数据流
|
||||
|
||||
从图文件到协议执行的完整链路:
|
||||
|
||||
```
|
||||
实验图 JSON
|
||||
↓ graphio.read_node_link_json()
|
||||
physical_setup_graph (NetworkX DiGraph)
|
||||
↓ ROS2WorkstationNode._setup_protocol_names(protocol_type)
|
||||
为每个 protocol_name 创建 ActionServer
|
||||
↓ 收到 Action Goal
|
||||
_create_protocol_execute_callback()
|
||||
↓ convert_from_ros_msg_with_mapping(goal, mapping)
|
||||
protocol_kwargs (Python dict)
|
||||
↓ 向 Host 查询 Resource 类型参数的当前状态
|
||||
protocol_kwargs 更新(vessel 带上 children、data 等)
|
||||
↓ protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)
|
||||
List[Dict] 动作序列
|
||||
↓ 逐步 execute_single_action / 并行 create_task
|
||||
子设备 ActionClient 执行
|
||||
```
|
||||
|
||||
### `_setup_protocol_names` 核心逻辑
|
||||
|
||||
```python
|
||||
def _setup_protocol_names(self, protocol_type):
|
||||
if isinstance(protocol_type, str):
|
||||
self.protocol_names = [p.strip() for p in protocol_type.split(",")]
|
||||
else:
|
||||
self.protocol_names = protocol_type
|
||||
self.protocol_action_mappings = {}
|
||||
for protocol_name in self.protocol_names:
|
||||
protocol_type = globals()[protocol_name] # 从 messages 模块取 Pydantic 类
|
||||
self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type)
|
||||
```
|
||||
|
||||
### `_create_protocol_execute_callback` 关键步骤
|
||||
|
||||
1. `convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])` — ROS Goal → Python dict
|
||||
2. 对 `Resource` 类型字段,通过 `resource_get` Service 查询 Host 的最新资源状态
|
||||
3. `protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)` — 调用编译函数
|
||||
4. 遍历 steps:`dict` 串行执行,`list` 并行执行
|
||||
5. `execute_single_action` 通过 `_action_clients[device_id]` 向子设备发送 Action Goal
|
||||
6. 执行完毕后通过 `resource_update` Service 更新资源状态
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试模式
|
||||
|
||||
### 2.1 协议文件内测试函数
|
||||
|
||||
许多协议文件末尾有 `test_*` 函数,主要测试参数解析工具:
|
||||
|
||||
```python
|
||||
def test_dissolve_protocol():
|
||||
"""测试溶解协议的各种参数解析"""
|
||||
volumes = ["10 mL", "?", 10.0, "1 L", "500 μL"]
|
||||
for vol in volumes:
|
||||
result = parse_volume_input(vol)
|
||||
print(f"体积解析: {vol} → {result}mL")
|
||||
|
||||
masses = ["2.9 g", "?", 2.5, "500 mg"]
|
||||
for mass in masses:
|
||||
result = parse_mass_input(mass)
|
||||
print(f"质量解析: {mass} → {result}g")
|
||||
```
|
||||
|
||||
### 2.2 使用 mock graph 测试协议生成器
|
||||
|
||||
推荐的端到端测试模式:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import networkx as nx
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def topology_graph():
|
||||
"""创建测试拓扑图"""
|
||||
G = nx.DiGraph()
|
||||
G.add_node("flask_1", **{"class": "flask", "type": "container"})
|
||||
G.add_node("stirrer_1", **{"class": "virtual_stirrer", "type": "device"})
|
||||
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"
|
||||
```
|
||||
|
||||
**要点:**
|
||||
- 用 `nx.DiGraph()` 构建最小拓扑
|
||||
- `add_node(id, **attrs)` 设置 `class`、`type`、`data` 等
|
||||
- `add_edge(src, dst)` 建立物理连接
|
||||
- 协议内的 `find_*` 函数依赖这些节点和边
|
||||
|
||||
---
|
||||
|
||||
## 3. 单位解析工具
|
||||
|
||||
路径:`unilabos/compile/utils/unit_parser.py`
|
||||
|
||||
| 函数 | 输入 | 返回 | 默认值 |
|
||||
|------|------|------|--------|
|
||||
| `parse_volume_input(input, default_unit)` | `"100 mL"`, `"2.5 L"`, `"500 μL"`, `10.0`, `"?"` | mL (float) | 50.0 |
|
||||
| `parse_mass_input(input)` | `"19.3 g"`, `"500 mg"`, `2.5`, `"?"` | g (float) | 1.0 |
|
||||
| `parse_time_input(input)` | `"30 min"`, `"1 h"`, `"300"`, `60.0`, `"?"` | 秒 (float) | 60.0 |
|
||||
|
||||
支持的单位:
|
||||
|
||||
- **体积**: mL, L, μL/uL, milliliter, liter, microliter
|
||||
- **质量**: g, mg, kg, gram, milligram, kilogram
|
||||
- **时间**: s/sec/second, min/minute, h/hr/hour, d/day
|
||||
|
||||
特殊值 `"?"`、`"unknown"`、`"tbd"` 返回默认值。
|
||||
|
||||
---
|
||||
|
||||
## 4. 复杂协议组合模式
|
||||
|
||||
以 `dissolve_protocol` 为例,展示如何组合多个子操作:
|
||||
|
||||
### 整体流程
|
||||
|
||||
```
|
||||
1. 解析参数 (parse_volume_input, parse_mass_input, parse_time_input)
|
||||
2. 设备发现 (find_connected_heatchill, find_connected_stirrer, find_solid_dispenser)
|
||||
3. 判断溶解类型 (液体 vs 固体)
|
||||
4. 组合动作序列:
|
||||
a. heat_chill_start / start_stir (启动加热/搅拌)
|
||||
b. wait (等待温度稳定)
|
||||
c. pump_protocol_with_rinsing (液体转移, 通过 extend 拼接)
|
||||
或 add_solid (固体加样)
|
||||
d. heat_chill / stir / wait (溶解等待)
|
||||
e. heat_chill_stop (停止加热)
|
||||
```
|
||||
|
||||
### 关键代码模式
|
||||
|
||||
**设备发现 → 条件组合:**
|
||||
|
||||
```python
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
solid_dispenser_id = find_solid_dispenser(G)
|
||||
|
||||
actions = []
|
||||
|
||||
# 启动阶段
|
||||
if heatchill_id and temp > 25.0:
|
||||
actions.append({
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp}
|
||||
})
|
||||
actions.append({"action_name": "wait", "action_kwargs": {"time": 30}})
|
||||
elif stirrer_id:
|
||||
actions.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "stir_speed": stir_speed}
|
||||
})
|
||||
|
||||
# 转移阶段(复用已有协议)
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G, from_vessel=solvent_vessel, to_vessel=vessel_id, volume=volume
|
||||
)
|
||||
actions.extend(pump_actions)
|
||||
|
||||
# 等待阶段
|
||||
if heatchill_id:
|
||||
actions.append({
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp, "time": time}
|
||||
})
|
||||
else:
|
||||
actions.append({"action_name": "wait", "action_kwargs": {"time": time}})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| 协议执行回调 | `unilabos/ros/nodes/presets/workstation.py` |
|
||||
| ROS 消息映射 | `unilabos/ros/msgs/message_converter.py` |
|
||||
| 物理拓扑图 | `unilabos/resources/graphio.py` (`physical_setup_graph`) |
|
||||
| 单位解析 | `unilabos/compile/utils/unit_parser.py` |
|
||||
| 容器解析 | `unilabos/compile/utils/vessel_parser.py` |
|
||||
| 溶解协议(组合示例) | `unilabos/compile/dissolve_protocol.py` |
|
||||
371
.cursor/skills/add-resource/SKILL.md
Normal file
371
.cursor/skills/add-resource/SKILL.md
Normal file
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: add-resource
|
||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Covers Bottle, Carrier, Deck, WareHouse definitions and registry YAML. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
||||
---
|
||||
|
||||
# 添加新物料资源
|
||||
|
||||
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认资源类型
|
||||
|
||||
向用户确认需要添加的资源类型:
|
||||
|
||||
| 类型 | 基类 | 用途 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
||||
|
||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
||||
|
||||
还需确认:
|
||||
- 资源所属的项目/场景(如 bioyond、battery、通用)
|
||||
- 尺寸参数(直径、高度、最大容积等)
|
||||
- 布局参数(行列数、间距等)
|
||||
|
||||
---
|
||||
|
||||
## 第二步:创建资源定义
|
||||
|
||||
### 文件位置
|
||||
|
||||
```
|
||||
unilabos/resources/
|
||||
├── <project>/ # 按项目分组
|
||||
│ ├── bottles.py # Bottle 工厂函数
|
||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
||||
│ ├── warehouses.py # WareHouse 工厂函数
|
||||
│ └── decks.py # Deck 类定义
|
||||
├── itemized_carrier.py # Bottle, BottleCarrier, ItemizedCarrier 基类
|
||||
├── warehouse.py # WareHouse 基类
|
||||
└── container.py # 通用容器
|
||||
```
|
||||
|
||||
### 2A. 添加 Bottle(工厂函数)
|
||||
|
||||
```python
|
||||
from unilabos.resources.itemized_carrier import Bottle
|
||||
|
||||
|
||||
def My_Reagent_Bottle(
|
||||
name: str,
|
||||
diameter: float = 70.0, # 瓶体直径 (mm)
|
||||
height: float = 120.0, # 瓶体高度 (mm)
|
||||
max_volume: float = 500000.0, # 最大容积 (μL)
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建试剂瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="My_Reagent_Bottle", # 唯一标识,用于注册表和物料映射
|
||||
)
|
||||
```
|
||||
|
||||
**Bottle 参数:**
|
||||
- `name`: 实例名称(运行时唯一)
|
||||
- `diameter`: 瓶体直径 (mm)
|
||||
- `height`: 瓶体高度 (mm)
|
||||
- `max_volume`: 最大容积 (**μL**,注意单位!500mL = 500000)
|
||||
- `barcode`: 条形码(可选)
|
||||
- `model`: 模型标识,与注册表 key 一致
|
||||
|
||||
### 2B. 添加 BottleCarrier(工厂函数)
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import ResourceHolder
|
||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||
|
||||
|
||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
||||
"""创建 3x2 六槽位载架"""
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3, # 列数
|
||||
num_items_y=2, # 行数
|
||||
dx=10.0, # X 起始偏移
|
||||
dy=10.0, # Y 起始偏移
|
||||
dz=5.0, # Z 偏移
|
||||
item_dx=42.0, # X 间距
|
||||
item_dy=35.0, # Y 间距
|
||||
size_x=20.0, # 槽位宽
|
||||
size_y=20.0, # 槽位深
|
||||
size_z=50.0, # 槽位高
|
||||
)
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=146.0, # 载架总宽
|
||||
size_y=80.0, # 载架总深
|
||||
size_z=55.0, # 载架总高
|
||||
sites=sites,
|
||||
model="My_6SlotCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
|
||||
# 预装 Bottle(可选)
|
||||
ordering = ["A01", "A02", "A03", "B01", "B02", "B03"]
|
||||
for i in range(6):
|
||||
carrier[i] = My_Reagent_Bottle(f"{ordering[i]}")
|
||||
|
||||
return carrier
|
||||
```
|
||||
|
||||
### 2C. 添加 WareHouse(工厂函数)
|
||||
|
||||
```python
|
||||
from unilabos.resources.warehouse import warehouse_factory
|
||||
|
||||
|
||||
def my_warehouse_4x4(name: str) -> "WareHouse":
|
||||
"""创建 4行x4列 堆栈仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, # 列数
|
||||
num_items_y=4, # 行数
|
||||
num_items_z=1, # 层数(通常为 1)
|
||||
dx=137.0, # X 起始偏移
|
||||
dy=96.0, # Y 起始偏移
|
||||
dz=120.0, # Z 起始偏移
|
||||
item_dx=137.0, # X 间距
|
||||
item_dy=125.0, # Y 间距
|
||||
item_dz=10.0, # Z 间距(多层时用)
|
||||
resource_size_x=127.0, # 槽位宽
|
||||
resource_size_y=85.0, # 槽位深
|
||||
resource_size_z=100.0, # 槽位高
|
||||
model="my_warehouse_4x4",
|
||||
)
|
||||
```
|
||||
|
||||
**`warehouse_factory` 参数说明:**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `num_items_x/y/z` | 列数/行数/层数 |
|
||||
| `dx, dy, dz` | 第一个槽位的起始坐标偏移 |
|
||||
| `item_dx, item_dy, item_dz` | 相邻槽位间距 |
|
||||
| `resource_size_x/y/z` | 单个槽位的物理尺寸 |
|
||||
| `col_offset` | 列命名偏移(如设 4 则从 A05 开始) |
|
||||
| `row_offset` | 行命名偏移(如设 5 则从 F 行开始) |
|
||||
| `layout` | 排序方式:`"col-major"`(列优先,默认)/ `"row-major"`(行优先) |
|
||||
| `removed_positions` | 要移除的位置索引列表 |
|
||||
|
||||
自动生成 `ResourceHolder` 槽位,命名规则为 `A01, B01, C01, D01, A02, ...`(列优先)或 `A01, A02, A03, A04, B01, ...`(行优先)。
|
||||
|
||||
### 2D. 添加 Deck(类定义)
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck, Coordinate
|
||||
|
||||
|
||||
class MyStation_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "MyStation_Deck",
|
||||
size_x: float = 2700.0,
|
||||
size_y: float = 1080.0,
|
||||
size_z: float = 1500.0,
|
||||
category: str = "deck",
|
||||
setup: bool = False,
|
||||
) -> None:
|
||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
self.warehouses = {
|
||||
"仓库A": my_warehouse_4x4("仓库A"),
|
||||
"仓库B": my_warehouse_4x4("仓库B"),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"仓库A": Coordinate(-200.0, 400.0, 0.0),
|
||||
"仓库B": Coordinate(2350.0, 400.0, 0.0),
|
||||
}
|
||||
for wh_name, wh in self.warehouses.items():
|
||||
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
||||
```
|
||||
|
||||
**Deck 要点:**
|
||||
- 继承 `pylabrobot.resources.Deck`
|
||||
- `setup()` 创建 WareHouse 并通过 `assign_child_resource` 放置到指定坐标
|
||||
- `setup` 参数控制是否在构造时自动调用 `setup()`(图文件中通过 `config.setup: true` 触发)
|
||||
|
||||
---
|
||||
|
||||
## 第三步:创建注册表 YAML
|
||||
|
||||
路径:`unilabos/registry/resources/<project>/<type>.yaml`
|
||||
|
||||
### Bottle 注册
|
||||
|
||||
```yaml
|
||||
My_Reagent_Bottle:
|
||||
category:
|
||||
- bottles
|
||||
class:
|
||||
module: unilabos.resources.my_project.bottles:My_Reagent_Bottle
|
||||
type: pylabrobot
|
||||
description: 我的试剂瓶
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
### Carrier 注册
|
||||
|
||||
```yaml
|
||||
My_6SlotCarrier:
|
||||
category:
|
||||
- bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.my_project.bottle_carriers:My_6SlotCarrier
|
||||
type: pylabrobot
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
### Deck 注册
|
||||
|
||||
```yaml
|
||||
MyStation_Deck:
|
||||
category:
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.resources.my_project.decks:MyStation_Deck
|
||||
type: pylabrobot
|
||||
description: 我的工作站 Deck
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
**注册表规则:**
|
||||
- `class.module` 格式为 `python.module.path:ClassName_or_FunctionName`
|
||||
- `class.type` 固定为 `pylabrobot`
|
||||
- Key(如 `My_Reagent_Bottle`)必须与工厂函数名 / 类名一致
|
||||
- `category` 按类型标注(`bottles`, `bottle_carriers`, `deck` 等)
|
||||
|
||||
---
|
||||
|
||||
## 第四步:在图文件中引用
|
||||
|
||||
### Deck 在工作站中的引用
|
||||
|
||||
工作站节点通过 `deck` 字段引用,Deck 作为子节点:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck"],
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_project.decks:MyStation_Deck"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"config": {"type": "MyStation_Deck", "setup": true}
|
||||
}
|
||||
```
|
||||
|
||||
### 物料类型映射(外部系统对接时)
|
||||
|
||||
如果工作站需要与外部系统同步物料,在 config 中配置 `material_type_mappings`:
|
||||
|
||||
```json
|
||||
"material_type_mappings": {
|
||||
"My_Reagent_Bottle": ["试剂瓶", "external-type-uuid"],
|
||||
"My_6SlotCarrier": ["六槽载架", "external-type-uuid"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第五步:注册 PLR 扩展(如需要)
|
||||
|
||||
如果添加了新的 Deck 类,需要在 `unilabos/resources/plr_additional_res_reg.py` 中导入,使 `find_subclass` 能发现它:
|
||||
|
||||
```python
|
||||
def register():
|
||||
from unilabos.resources.my_project.decks import MyStation_Deck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:验证
|
||||
|
||||
```bash
|
||||
# 1. 资源可导入
|
||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
||||
|
||||
# 2. Deck 可创建
|
||||
python -c "
|
||||
from unilabos.resources.my_project.decks import MyStation_Deck
|
||||
d = MyStation_Deck('test', setup=True)
|
||||
print(d.children)
|
||||
"
|
||||
|
||||
# 3. 启动测试
|
||||
unilab -g <graph>.json --complete_registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
资源接入进度:
|
||||
- [ ] 1. 确定资源类型(Bottle / Carrier / WareHouse / Deck)
|
||||
- [ ] 2. 创建资源定义(工厂函数/类)
|
||||
- [ ] 3. 创建注册表 YAML (unilabos/registry/resources/<project>/<type>.yaml)
|
||||
- [ ] 4. 在图文件中引用(如需要)
|
||||
- [ ] 5. 注册 PLR 扩展(Deck 类需要)
|
||||
- [ ] 6. 验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现复杂资源系统时,详见 [reference.md](reference.md):类继承体系完整图、序列化/反序列化流程、Bioyond 物料双向同步、非瓶类资源(ElectrodeSheet / Magazine)、仓库工厂 layout 模式。
|
||||
|
||||
---
|
||||
|
||||
## 现有资源参考
|
||||
|
||||
| 项目 | Bottles | Carriers | WareHouses | Decks |
|
||||
|------|---------|----------|------------|-------|
|
||||
| bioyond | `bioyond/bottles.py` | `bioyond/bottle_carriers.py` | `bioyond/warehouses.py`, `YB_warehouses.py` | `bioyond/decks.py` |
|
||||
| battery | — | `battery/bottle_carriers.py` | — | — |
|
||||
| 通用 | — | — | `warehouse.py` | — |
|
||||
|
||||
### 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
| 资源注册表 | `unilabos/registry/resources/` |
|
||||
| 图文件加载 | `unilabos/resources/graphio.py` |
|
||||
| 资源跟踪器 | `unilabos/resources/resource_tracker.py` |
|
||||
292
.cursor/skills/add-resource/reference.md
Normal file
292
.cursor/skills/add-resource/reference.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 资源高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 类继承体系
|
||||
|
||||
```
|
||||
PyLabRobot
|
||||
├── Resource (PLR 基类)
|
||||
│ ├── Well
|
||||
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
||||
│ ├── Deck
|
||||
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
||||
│ ├── ResourceHolder → 槽位占位符
|
||||
│ └── Container
|
||||
│ └── Battery (unilabos) → 组装好的电池
|
||||
│
|
||||
├── ItemizedCarrier (unilabos, 继承 Resource)
|
||||
│ ├── BottleCarrier (unilabos) → 瓶载架
|
||||
│ └── WareHouse (unilabos) → 堆栈仓库
|
||||
│
|
||||
├── ItemizedResource (PLR)
|
||||
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
||||
│
|
||||
└── ResourceStack (PLR)
|
||||
└── Magazine (unilabos) → 子弹夹洞位
|
||||
```
|
||||
|
||||
### Bottle 类细节
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def __init__(self, name, diameter, height, max_volume,
|
||||
size_x=0.0, size_y=0.0, size_z=0.0,
|
||||
barcode=None, category="container", model=None, **kwargs):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
||||
size_y=diameter,
|
||||
size_z=height, # PLR 用 height 作为 size_z
|
||||
max_volume=max_volume,
|
||||
category=category,
|
||||
model=model,
|
||||
bottom_type="flat",
|
||||
cross_section_type="circle"
|
||||
)
|
||||
```
|
||||
|
||||
注意 `size_x = size_y = diameter`,`size_z = height`。
|
||||
|
||||
### ItemizedCarrier 核心方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
||||
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
||||
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
||||
| `capacity` | 总槽位数 |
|
||||
| `sites` | 所有槽位字典 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 序列化与反序列化
|
||||
|
||||
### PLR ↔ UniLab 转换
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
||||
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
||||
|
||||
### `from_plr_resources` 流程
|
||||
|
||||
```
|
||||
PLR Resource
|
||||
↓ build_uuid_mapping (递归生成 UUID)
|
||||
↓ resource.serialize() → dict
|
||||
↓ resource.serialize_all_state() → states
|
||||
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
||||
ResourceTreeSet
|
||||
```
|
||||
|
||||
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
||||
|
||||
### `to_plr_resources` 流程
|
||||
|
||||
```
|
||||
ResourceTreeSet
|
||||
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
||||
↓ node_to_plr_dict (转为 PLR 字典格式)
|
||||
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
||||
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
||||
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
||||
PLR Resource
|
||||
```
|
||||
|
||||
### Bottle 序列化
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
return {**data, "diameter": self.diameter, "height": self.height}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal=False):
|
||||
barcode_data = data.pop("barcode", None)
|
||||
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
||||
if barcode_data and isinstance(barcode_data, str):
|
||||
instance.barcode = barcode_data
|
||||
return instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Bioyond 物料同步
|
||||
|
||||
### 双向转换函数
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
||||
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
||||
|
||||
### `resource_bioyond_to_plr` 流程
|
||||
|
||||
```
|
||||
Bioyond 物料列表
|
||||
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
||||
↓ 对每个物料:
|
||||
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
||||
initialize_resource({"name": unique_name, "class": model})
|
||||
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
||||
↓ 处理 detail (子物料/坐标)
|
||||
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
||||
PLR 资源列表
|
||||
```
|
||||
|
||||
### `resource_plr_to_bioyond` 流程
|
||||
|
||||
```
|
||||
PLR 资源列表
|
||||
↓ 遍历每个资源:
|
||||
载架(capacity > 1): 生成 details 子物料 + 坐标
|
||||
单瓶: 直接映射
|
||||
↓ type_mapping 查找 typeId
|
||||
↓ warehouse_mapping 查找位置 UUID
|
||||
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
||||
Bioyond 物料列表
|
||||
```
|
||||
|
||||
### BioyondResourceSynchronizer
|
||||
|
||||
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
||||
|
||||
```python
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
all_data = []
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
||||
unilab_resources = resource_bioyond_to_plr(
|
||||
all_data,
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
deck=self.workstation.deck
|
||||
)
|
||||
# 更新 deck 上的资源
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 非瓶类资源
|
||||
|
||||
### ElectrodeSheet(极片)
|
||||
|
||||
路径:`unilabos/resources/battery/electrode_sheet.py`
|
||||
|
||||
```python
|
||||
class ElectrodeSheet(ResourcePLR):
|
||||
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
||||
_unilabos_state = {
|
||||
"diameter": 0.0,
|
||||
"thickness": 0.0,
|
||||
"mass": 0.0,
|
||||
"material_type": "",
|
||||
"color": "",
|
||||
"info": "",
|
||||
}
|
||||
```
|
||||
|
||||
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
||||
|
||||
### Battery(电池)
|
||||
|
||||
```python
|
||||
class Battery(Container):
|
||||
"""组装好的电池"""
|
||||
_unilabos_state = {
|
||||
"color": "",
|
||||
"electrolyte_name": "",
|
||||
"open_circuit_voltage": 0.0,
|
||||
}
|
||||
```
|
||||
|
||||
### Magazine / MagazineHolder(子弹夹)
|
||||
|
||||
```python
|
||||
class Magazine(ResourceStack):
|
||||
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
||||
# direction, max_sheets
|
||||
|
||||
class MagazineHolder(ItemizedResource):
|
||||
"""多洞位子弹夹"""
|
||||
# hole_diameter, hole_depth, max_sheets_per_hole
|
||||
```
|
||||
|
||||
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓库工厂模式参考
|
||||
|
||||
### 实际 warehouse 工厂函数示例
|
||||
|
||||
```python
|
||||
# 行优先 4x4 仓库
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="row-major", # A01,A02,A03,A04, B01,...
|
||||
)
|
||||
|
||||
# 右侧 4x4 仓库(列名偏移)
|
||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
col_offset=4, # A05,A06,A07,A08
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
# 竖向仓库(站内试剂存放)
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1, num_items_y=2, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="vertical-col-major",
|
||||
)
|
||||
|
||||
# 行偏移(F 行开始)
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, num_items_y=5, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
||||
row_offset=row_offset, # 0→A行起,5→F行起
|
||||
layout="row-major",
|
||||
)
|
||||
```
|
||||
|
||||
### layout 类型说明
|
||||
|
||||
| layout | 命名顺序 | 适用场景 |
|
||||
|--------|---------|---------|
|
||||
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
||||
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
||||
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
||||
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
||||
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
||||
| 电池资源 | `unilabos/resources/battery/` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
500
.cursor/skills/add-workstation/SKILL.md
Normal file
500
.cursor/skills/add-workstation/SKILL.md
Normal file
@@ -0,0 +1,500 @@
|
||||
---
|
||||
name: add-workstation
|
||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Walks through workstation type selection, sub-device composition, external system integration, driver creation, registry YAML, deck setup, and graph file configuration. Use when the user wants to add/integrate a new workstation, create a workstation driver, configure a station with sub-devices, set up deck and materials, or mentions 工作站/工站/station/workstation.
|
||||
---
|
||||
|
||||
# Uni-Lab-OS 工作站接入指南
|
||||
|
||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统(PLR Deck)和工作流引擎。本指南覆盖从需求分析到验证的全流程。
|
||||
|
||||
> **前置知识**:工作站接入基于 `docs/ai_guides/add_device.md` 的通用设备接入框架,但有显著差异。阅读本指南前无需先读通用指南。
|
||||
|
||||
## 第一步:确定工作站类型
|
||||
|
||||
向用户确认以下信息:
|
||||
|
||||
**Q1: 工作站的业务场景?**
|
||||
|
||||
| 类型 | 基类 | 适用场景 | 示例 |
|
||||
|------|------|----------|------|
|
||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(过滤、转移、加热等) | FilterProtocolStation |
|
||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 系统对接,有专属 API | BioyondStation |
|
||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件,无外部系统 | CoinCellAssembly |
|
||||
|
||||
**Q2: 工作站英文名称?**(如 `my_reaction_station`)
|
||||
|
||||
**Q3: 与外部系统的交互方式?**
|
||||
|
||||
| 方式 | 适用场景 | 需要的配置 |
|
||||
|------|----------|-----------|
|
||||
| 无外部系统 | Protocol 工作站、纯硬件控制 | 无 |
|
||||
| HTTP API | LIMS/MES 系统(如 Bioyond) | `api_host`, `api_key` |
|
||||
| Modbus TCP | PLC 控制 | `address`, `port` |
|
||||
| OPC UA | 工业设备 | `url` |
|
||||
|
||||
**Q4: 子设备组成?**
|
||||
- 列出所有子设备(如反应器、泵、阀、传感器等)
|
||||
- 哪些是已有设备类型?哪些需要新增?
|
||||
- 子设备之间的硬件代理关系(如泵通过串口设备通信)
|
||||
|
||||
**Q5: 物料管理需求?**
|
||||
- 是否需要 Deck(物料面板)?
|
||||
- 物料类型(plate、tip_rack、bottle 等)
|
||||
- 是否需要与外部物料系统同步?
|
||||
|
||||
---
|
||||
|
||||
## 第二步:理解工作站架构
|
||||
|
||||
工作站与普通设备的核心差异:
|
||||
|
||||
| 维度 | 普通设备 | 工作站 |
|
||||
|------|---------|--------|
|
||||
| 基类 | 无(纯 Python 类) | `WorkstationBase` 或 `ProtocolNode` |
|
||||
| ROS 节点 | `BaseROS2DeviceNode` | `ROS2WorkstationNode` |
|
||||
| 状态管理 | `self.data` 字典 | 通常不用 `self.data`,用 `@property` 直接访问 |
|
||||
| 子设备 | 无 | `children` 列表,通过 `self._children` 访问 |
|
||||
| 物料 | 无 | `self.deck`(PLR Deck) |
|
||||
| 图文件角色 | `parent: null` 或 `parent: "<station>"` | `parent: null`,含 `children` 和 `deck` |
|
||||
|
||||
### 继承体系
|
||||
|
||||
`WorkstationBase` (ABC) → `ProtocolNode` (通用协议) / `BioyondWorkstation` (→ ReactionStation, DispensingStation) / `CoinCellAssemblyWorkstation` (硬件控制)
|
||||
|
||||
### ROS 层
|
||||
|
||||
`ROS2WorkstationNode` 额外负责:初始化 children 子设备节点、为子设备创建 ActionClient、配置硬件代理、为 protocol_type 创建协议 ActionServer。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:创建驱动文件
|
||||
|
||||
文件路径:`unilabos/devices/workstation/<station_name>/<station_name>.py`
|
||||
|
||||
### 模板 A:基于外部系统的工作站
|
||||
|
||||
适用于与 LIMS/MES 等外部系统对接的场景。
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
"""工作站描述"""
|
||||
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict = None,
|
||||
deck: Optional[Deck] = None,
|
||||
protocol_type: list = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"MyWorkstation")
|
||||
|
||||
# 外部系统连接配置
|
||||
self.api_host = self.config.get("api_host", "")
|
||||
self.api_key = self.config.get("api_key", "")
|
||||
|
||||
# 工作站业务状态(不同于 self.data 模式)
|
||||
self._status = "Idle"
|
||||
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode") -> None:
|
||||
super().post_init(ros_node)
|
||||
# 在这里启动后台服务、连接监控等
|
||||
|
||||
# ============ 子设备访问 ============
|
||||
|
||||
def _get_child_device(self, device_id: str):
|
||||
"""通过 ID 获取子设备节点"""
|
||||
return self._children.get(device_id)
|
||||
|
||||
# ============ 动作方法 ============
|
||||
|
||||
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
||||
"""启动调度器"""
|
||||
return {"success": True}
|
||||
|
||||
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
||||
"""创建工单"""
|
||||
return {"success": True}
|
||||
|
||||
# ============ 属性 ============
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
return "[]"
|
||||
|
||||
@property
|
||||
def material_info(self) -> str:
|
||||
return "{}"
|
||||
```
|
||||
|
||||
### 模板 B:基于硬件控制的工作站
|
||||
|
||||
适用于直接与 PLC/硬件通信的场景。
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
class MyHardwareWorkstation(WorkstationBase):
|
||||
"""硬件控制工作站"""
|
||||
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict = None,
|
||||
deck: Optional[Deck] = None,
|
||||
address: str = "192.168.1.100",
|
||||
port: str = "502",
|
||||
debug_mode: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
self.config = config or {}
|
||||
self.address = address
|
||||
self.port = int(port)
|
||||
self.debug_mode = debug_mode
|
||||
self.logger = logging.getLogger("MyHardwareWorkstation")
|
||||
|
||||
# 初始化通信客户端
|
||||
if not debug_mode:
|
||||
from unilabos.device_comms.modbus_plc.client import ModbusTcpClient
|
||||
self.client = ModbusTcpClient(host=self.address, port=self.port)
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode") -> None:
|
||||
super().post_init(ros_node)
|
||||
|
||||
# ============ 硬件读写 ============
|
||||
|
||||
def _read_register(self, name: str):
|
||||
"""读取 Modbus 寄存器"""
|
||||
if self.debug_mode:
|
||||
return 0
|
||||
# 实际读取逻辑
|
||||
pass
|
||||
|
||||
# ============ 动作方法 ============
|
||||
|
||||
async def start_process(self, **kwargs) -> Dict[str, Any]:
|
||||
"""启动加工流程"""
|
||||
return {"success": True}
|
||||
|
||||
async def stop_process(self, **kwargs) -> Dict[str, Any]:
|
||||
"""停止加工流程"""
|
||||
return {"success": True}
|
||||
|
||||
# ============ 属性(从硬件实时读取)============
|
||||
|
||||
@property
|
||||
def sys_status(self) -> str:
|
||||
return str(self._read_register("SYS_STATUS"))
|
||||
```
|
||||
|
||||
### 模板 C:Protocol 工作站
|
||||
|
||||
适用于标准化学操作协议的场景,直接使用 `ProtocolNode`。
|
||||
|
||||
```python
|
||||
from typing import List, Optional
|
||||
from pylabrobot.resources import Resource as PLRResource
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
||||
|
||||
|
||||
class MyProtocolStation(ProtocolNode):
|
||||
"""Protocol 工作站 — 使用标准化学操作协议"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
protocol_type: List[str],
|
||||
deck: Optional[PLRResource] = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(protocol_type=protocol_type, deck=deck, *args, **kwargs)
|
||||
```
|
||||
|
||||
> Protocol 工作站通常不需要自定义驱动类,直接使用 `ProtocolNode` 并在注册表和图文件中配置 `protocol_type` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 第四步:创建子设备驱动(如需要)
|
||||
|
||||
工作站的子设备本身是独立设备。按 `docs/ai_guides/add_device.md` 的标准流程创建。
|
||||
|
||||
子设备的关键约束:
|
||||
- 在图文件中 `parent` 指向工作站 ID
|
||||
- 图文件中在工作站的 `children` 数组里列出
|
||||
- 如需硬件代理,在子设备的 `config.hardware_interface.name` 指向通信设备 ID
|
||||
|
||||
---
|
||||
|
||||
## 第五步:创建注册表 YAML
|
||||
|
||||
路径:`unilabos/registry/devices/<station_name>.yaml`
|
||||
|
||||
### 最小配置
|
||||
|
||||
```yaml
|
||||
my_workstation:
|
||||
category:
|
||||
- workstation
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_station:MyWorkstation
|
||||
type: python
|
||||
```
|
||||
|
||||
启动时 `--complete_registry` 自动补全 `status_types` 和 `action_value_mappings`。
|
||||
|
||||
### 完整配置参考
|
||||
|
||||
```yaml
|
||||
my_workstation:
|
||||
description: "我的工作站"
|
||||
version: "1.0.0"
|
||||
category:
|
||||
- workstation
|
||||
- my_category
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_station:MyWorkstation
|
||||
type: python
|
||||
status_types:
|
||||
workflow_sequence: String
|
||||
material_info: String
|
||||
action_value_mappings:
|
||||
scheduler_start:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal: {}
|
||||
result:
|
||||
success: success
|
||||
create_order:
|
||||
type: UniLabJsonCommandAsync
|
||||
goal:
|
||||
json_str: json_str
|
||||
result:
|
||||
success: success
|
||||
init_param_schema:
|
||||
config:
|
||||
type: object
|
||||
deck:
|
||||
type: object
|
||||
protocol_type:
|
||||
type: array
|
||||
```
|
||||
|
||||
### 子设备注册表
|
||||
|
||||
子设备有独立的注册表文件,需要在 `category` 中包含工作站标识:
|
||||
|
||||
```yaml
|
||||
my_reactor:
|
||||
category:
|
||||
- reactor
|
||||
- my_workstation
|
||||
class:
|
||||
module: unilabos.devices.workstation.my_station.my_reactor:MyReactor
|
||||
type: python
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:配置 Deck 资源(如需要)
|
||||
|
||||
如果工作站有物料管理需求,需要定义 Deck 类。
|
||||
|
||||
### 使用已有 Deck 类
|
||||
|
||||
查看 `unilabos/resources/` 目录下是否有适用的 Deck 类。
|
||||
|
||||
### 创建自定义 Deck
|
||||
|
||||
在 `unilabos/resources/<category>/decks.py` 中定义:
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck
|
||||
from pylabrobot.resources.coordinate import Coordinate
|
||||
|
||||
|
||||
def MyStation_Deck(name: str = "MyStation_Deck") -> Deck:
|
||||
deck = Deck(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
|
||||
# 在 deck 上定义子资源位置(carrier、plate 等)
|
||||
return deck
|
||||
```
|
||||
|
||||
在 `unilabos/resources/<category>/` 下注册或通过注册表引用。
|
||||
|
||||
---
|
||||
|
||||
## 第七步:配置图文件
|
||||
|
||||
图文件路径:`unilabos/test/experiments/<station_name>.json`
|
||||
|
||||
### 完整结构
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"name": "my_station",
|
||||
"children": ["my_deck", "sub_device_1", "sub_device_2"],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "my_workstation",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_KEY"
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_module.decks:MyStation_Deck"
|
||||
}
|
||||
},
|
||||
"size_x": 2700.0,
|
||||
"size_y": 1080.0,
|
||||
"size_z": 1500.0,
|
||||
"protocol_type": [],
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"name": "my_deck",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "MyStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "sub_device_1",
|
||||
"name": "sub_device_1",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "device",
|
||||
"class": "sub_device_registry_name",
|
||||
"position": {"x": 100, "y": 0, "z": 0},
|
||||
"config": {},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 图文件规则
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 节点唯一标识,与 `children` 数组中的引用一致 |
|
||||
| `children` | 包含 deck ID 和所有子设备 ID |
|
||||
| `parent` | 工作站节点为 `null`;子设备/deck 指向工作站 ID |
|
||||
| `type` | 工作站和子设备为 `"device"`;deck 为 `"deck"` |
|
||||
| `class` | 对应注册表中的设备名 |
|
||||
| `deck.data._resource_child_name` | 必须与 deck 节点的 `id` 一致 |
|
||||
| `deck.data._resource_type` | Deck 工厂函数的完整 Python 路径 |
|
||||
| `protocol_type` | Protocol 工作站填入协议名列表;否则为 `[]` |
|
||||
| `config` | 传入驱动 `__init__` 的 `config` 参数 |
|
||||
|
||||
---
|
||||
|
||||
## 第八步:验证
|
||||
|
||||
```bash
|
||||
# 1. 模块可导入
|
||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
||||
|
||||
# 2. 注册表补全
|
||||
unilab -g <graph>.json --complete_registry
|
||||
|
||||
# 3. 启动测试
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
实现外部系统对接型工作站时,详见 [reference.md](reference.md):RPC 客户端、HTTP 回调服务、连接监控、Config 结构模式(material_type_mappings / warehouse_mapping / workflow_mappings)、ResourceSynchronizer、update_resource、工作流序列、站间物料转移、post_init 完整模式。
|
||||
|
||||
---
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.__init__` 需要 `deck` 参数
|
||||
2. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
||||
3. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
||||
4. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
5. **子设备在图文件中声明** — 不在驱动代码中创建子设备实例
|
||||
6. **`deck` 配置中的 `_resource_child_name` 必须与 deck 节点 ID 一致**
|
||||
7. **Protocol 工作站优先使用 `ProtocolNode`** — 不需要自定义类
|
||||
|
||||
---
|
||||
|
||||
## 工作流清单
|
||||
|
||||
```
|
||||
工作站接入进度:
|
||||
- [ ] 1. 确定工作站类型(Protocol / 外部系统 / 硬件控制)
|
||||
- [ ] 2. 确认子设备组成和物料需求
|
||||
- [ ] 3. 创建工作站驱动 unilabos/devices/workstation/<name>/<name>.py
|
||||
- [ ] 4. 创建子设备驱动(如需要,按 add_device.md 流程)
|
||||
- [ ] 5. 创建注册表 unilabos/registry/devices/<name>.yaml
|
||||
- [ ] 6. 创建/选择 Deck 资源类(如需要)
|
||||
- [ ] 7. 配置图文件 unilabos/test/experiments/<name>.json
|
||||
- [ ] 8. 验证:可导入 + 注册表补全 + 启动测试
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 现有工作站参考
|
||||
|
||||
| 工作站 | 注册表名 | 驱动类 | 类型 |
|
||||
|--------|----------|--------|------|
|
||||
| Protocol 通用 | `workstation` | `ProtocolNode` | Protocol |
|
||||
| Bioyond 反应站 | `reaction_station.bioyond` | `BioyondReactionStation` | 外部系统 |
|
||||
| Bioyond 配液站 | `bioyond_dispensing_station` | `BioyondDispensingStation` | 外部系统 |
|
||||
| 纽扣电池组装 | `coincellassemblyworkstation_device` | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
||||
|
||||
### 参考文件路径
|
||||
|
||||
- 基类: `unilabos/devices/workstation/workstation_base.py`
|
||||
- Bioyond 基类: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||
- 反应站: `unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py`
|
||||
- 配液站: `unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py`
|
||||
- 纽扣电池: `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||
- ROS 节点: `unilabos/ros/nodes/presets/workstation.py`
|
||||
- 图文件: `unilabos/test/experiments/reaction_station_bioyond.json`, `dispensing_station_bioyond.json`
|
||||
371
.cursor/skills/add-workstation/reference.md
Normal file
371
.cursor/skills/add-workstation/reference.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 工作站高级模式参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
||||
Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 外部系统集成模式
|
||||
|
||||
### 1.1 RPC 客户端
|
||||
|
||||
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
||||
|
||||
```python
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
|
||||
|
||||
class MySystemRPC(BaseRequest):
|
||||
"""外部系统 RPC 客户端"""
|
||||
|
||||
def __init__(self, host: str, api_key: str):
|
||||
super().__init__(host)
|
||||
self.api_key = api_key
|
||||
|
||||
def _request(self, endpoint: str, data: dict = None) -> dict:
|
||||
return self.post(
|
||||
url=f"{self.host}/api/{endpoint}",
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": data or {},
|
||||
},
|
||||
)
|
||||
|
||||
def query_status(self) -> dict:
|
||||
return self._request("status/query")
|
||||
|
||||
def create_order(self, order_data: dict) -> dict:
|
||||
return self._request("order/create", order_data)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
||||
|
||||
### 1.2 HTTP 回调服务
|
||||
|
||||
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, config=None, deck=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
http_cfg = self.config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_cfg.get("http_service_port", 8080),
|
||||
}
|
||||
self.http_service = None
|
||||
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
```
|
||||
|
||||
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
||||
|
||||
| 端点 | 调用的工作站方法 |
|
||||
|------|-----------------|
|
||||
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
||||
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
||||
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
||||
| `/report/material_change` | `process_material_change_report(report_data)` |
|
||||
| `/report/error_handling` | `handle_external_error(error_data)` |
|
||||
|
||||
实现对应方法即可接收回调:
|
||||
|
||||
```python
|
||||
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
||||
"""处理步骤完成报告"""
|
||||
step_name = report_request.data.get("stepName")
|
||||
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
||||
"""处理订单完成报告"""
|
||||
order_code = report_request.data.get("orderCode")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
||||
|
||||
### 1.3 连接监控
|
||||
|
||||
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
||||
|
||||
```python
|
||||
class ConnectionMonitor:
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 调用外部系统接口检测连接
|
||||
self.workstation.hardware_interface.ping()
|
||||
status = "online"
|
||||
except Exception:
|
||||
status = "offline"
|
||||
time.sleep(self.check_interval)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Config 结构模式
|
||||
|
||||
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
||||
|
||||
### 2.1 外部系统连接
|
||||
|
||||
```json
|
||||
{
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 HTTP 回调服务
|
||||
|
||||
```json
|
||||
{
|
||||
"http_service_config": {
|
||||
"http_service_host": "127.0.0.1",
|
||||
"http_service_port": 8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 物料类型映射
|
||||
|
||||
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"material_type_mappings": {
|
||||
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 仓库映射
|
||||
|
||||
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"仓库名": {
|
||||
"uuid": "warehouse-uuid",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-A01",
|
||||
"A02": "site-uuid-A02"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 工作流映射
|
||||
|
||||
将内部工作流名映射到外部系统的工作流 ID。
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow_mappings": {
|
||||
"internal_workflow_name": "external-workflow-uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 物料默认参数
|
||||
|
||||
```json
|
||||
{
|
||||
"material_default_parameters": {
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 资源同步机制
|
||||
|
||||
### 3.1 ResourceSynchronizer
|
||||
|
||||
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def __init__(self, workstation, api_client):
|
||||
super().__init__(workstation)
|
||||
self.api_client = api_client
|
||||
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统拉取物料到 deck"""
|
||||
external_materials = self.api_client.list_materials()
|
||||
for material in external_materials:
|
||||
plr_resource = self._convert_to_plr(material)
|
||||
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
||||
return True
|
||||
|
||||
def sync_to_external(self, plr_resource) -> bool:
|
||||
"""将 deck 中的物料变更推送到外部系统"""
|
||||
external_data = self._convert_from_plr(plr_resource)
|
||||
self.api_client.update_material(external_data)
|
||||
return True
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统推送的物料变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
### 3.2 update_resource — 上传资源树到云端
|
||||
|
||||
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
||||
|
||||
```python
|
||||
# 在 post_init 中上传初始 deck
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 在动作方法中更新特定资源
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [updated_plate]}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 工作流序列管理
|
||||
|
||||
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._workflow_sequence = []
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
"""返回 JSON 字符串,ROS 自动发布"""
|
||||
import json
|
||||
return json.dumps(self._workflow_sequence)
|
||||
|
||||
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
||||
"""添加工作流到队列"""
|
||||
self._workflow_sequence.append({
|
||||
"name": workflow_name,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
})
|
||||
return {"success": True}
|
||||
|
||||
async def clear_workflows(self) -> Dict[str, Any]:
|
||||
"""清空工作流队列"""
|
||||
self._workflow_sequence = []
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 站间物料转移
|
||||
|
||||
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
||||
|
||||
```python
|
||||
async def transfer_materials_to_another_station(
|
||||
self,
|
||||
target_device_id: str,
|
||||
transfer_groups: list,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""将物料转移到另一个工作站"""
|
||||
target_node = self._children.get(target_device_id)
|
||||
if not target_node:
|
||||
# 通过 ROS 节点查找非子设备的目标站
|
||||
pass
|
||||
|
||||
for group in transfer_groups:
|
||||
resource = self.find_resource_by_name(group["resource_name"])
|
||||
# 从本站 deck 移除
|
||||
resource.unassign()
|
||||
# 调用目标站的接收方法
|
||||
# ...
|
||||
|
||||
return {"success": True, "transferred": len(transfer_groups)}
|
||||
```
|
||||
|
||||
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
||||
|
||||
---
|
||||
|
||||
## 6. post_init 完整模式
|
||||
|
||||
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
||||
|
||||
```python
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
|
||||
# 1. 初始化外部系统客户端(此时 config 已可用)
|
||||
self.rpc_client = MySystemRPC(
|
||||
host=self.config.get("api_host"),
|
||||
api_key=self.config.get("api_key"),
|
||||
)
|
||||
self.hardware_interface = self.rpc_client
|
||||
|
||||
# 2. 启动连接监控
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
|
||||
# 3. 启动 HTTP 回调服务
|
||||
if hasattr(self, '_http_service_config'):
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
|
||||
# 4. 上传 deck 到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 5. 初始化资源同步器(可选)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
||||
```
|
||||
381
.cursor/skills/edit-experiment-graph/SKILL.md
Normal file
381
.cursor/skills/edit-experiment-graph/SKILL.md
Normal file
@@ -0,0 +1,381 @@
|
||||
---
|
||||
name: edit-experiment-graph
|
||||
description: Guide for creating and editing experiment graph files in Uni-Lab-OS (创建/编辑实验组态图). Covers node types, link types, parent-child relationships, deck configuration, and common graph patterns. Use when the user wants to create a graph file, edit an experiment configuration, set up device topology, or mentions 图文件/graph/组态/拓扑/实验图/experiment JSON.
|
||||
---
|
||||
|
||||
# 创建/编辑实验组态图
|
||||
|
||||
实验图(Graph File)定义设备拓扑、物理连接和物料配置。系统启动时加载图文件,初始化所有设备和连接关系。
|
||||
|
||||
路径:`unilabos/test/experiments/<name>.json`
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认需求
|
||||
|
||||
向用户确认:
|
||||
|
||||
| 信息 | 说明 |
|
||||
|------|------|
|
||||
| 场景类型 | 单设备调试 / 多设备联调 / 工作站完整图 |
|
||||
| 包含的设备 | 设备 ID、注册表 class 名、配置参数 |
|
||||
| 连接关系 | 物理连接(管道)/ 通信连接(串口)/ 无连接 |
|
||||
| 父子关系 | 是否有工作站包含子设备 |
|
||||
| 物料需求 | 是否需要 Deck、容器、试剂瓶 |
|
||||
|
||||
---
|
||||
|
||||
## 第二步:JSON 顶层结构
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
> `links` 也可写作 `edges`,加载时两者等效。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:定义 Nodes
|
||||
|
||||
### 节点字段
|
||||
|
||||
| 字段 | 类型 | 必需 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `id` | string | **是** | — | 节点唯一标识,links 和 children 中引用此值 |
|
||||
| `class` | string | **是** | — | 对应注册表名(设备/资源 YAML 的 key),容器可为 `null` |
|
||||
| `name` | string | 否 | 同 `id` | 显示名称,缺省时自动用 `id` |
|
||||
| `type` | string | 否 | `"device"` | 节点类型(见下表),缺省时自动设为 `"device"` |
|
||||
| `children` | string[] | 否 | `[]` | 子节点 ID 列表 |
|
||||
| `parent` | string\|null | 否 | `null` | 父节点 ID,顶层设备为 `null` |
|
||||
| `position` | object | 否 | `{x:0,y:0,z:0}` | 空间坐标 |
|
||||
| `config` | object | 否 | `{}` | 传给驱动 `__init__` 的参数 |
|
||||
| `data` | object | 否 | `{}` | 初始运行状态 |
|
||||
| `size_x/y/z` | float | 否 | — | 节点物理尺寸(工作站节点常用) |
|
||||
|
||||
> 非标准字段(如 `api_host`)会自动移入 `config`。
|
||||
|
||||
### 节点类型
|
||||
|
||||
| `type` | 用途 | `class` 要求 |
|
||||
|--------|------|-------------|
|
||||
| `device` | 设备(默认) | 注册表中的设备名 |
|
||||
| `deck` | 工作台面 | Deck 工厂函数/类名 |
|
||||
| `container` | 容器(烧瓶、反应釜) | `null` 或具体容器类名 |
|
||||
|
||||
### 设备节点模板
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_device",
|
||||
"name": "我的设备",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "registry_device_name",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"port": "/dev/ttyUSB0",
|
||||
"baudrate": 115200
|
||||
},
|
||||
"data": {
|
||||
"status": "Idle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 容器节点模板
|
||||
|
||||
容器用于协议系统中表示试剂瓶、反应釜等,`class` 通常为 `null`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "flask_DMF",
|
||||
"name": "DMF试剂瓶",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {"x": 200, "y": 500, "z": 0},
|
||||
"config": {"max_volume": 1000.0},
|
||||
"data": {
|
||||
"liquid": [{"liquid_type": "DMF", "liquid_volume": 800.0}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deck 节点模板
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_deck",
|
||||
"name": "my_deck",
|
||||
"children": [],
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "MyStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "MyStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第四步:定义 Links
|
||||
|
||||
### Link 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `source` | string | 源节点 ID |
|
||||
| `target` | string | 目标节点 ID |
|
||||
| `type` | string | `"physical"` / `"fluid"` / `"communication"` |
|
||||
| `port` | object | 端口映射 `{source_id: "port_name", target_id: "port_name"}` |
|
||||
|
||||
### 物理/流体连接
|
||||
|
||||
设备间的管道连接,协议系统用此查找路径:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "multiway_valve_1",
|
||||
"target": "flask_DMF",
|
||||
"type": "fluid",
|
||||
"port": {
|
||||
"multiway_valve_1": "2",
|
||||
"flask_DMF": "outlet"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 通信连接
|
||||
|
||||
设备间的串口/IO 通信代理,加载时自动将端口信息写入目标设备 config:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "serial_device",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_1": "port",
|
||||
"serial_device": "port"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第五步:父子关系与工作站配置
|
||||
|
||||
### 工作站 + 子设备
|
||||
|
||||
工作站节点的 `children` 列出所有子节点 ID,子节点的 `parent` 指向工作站:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck", "pump_1", "valve_1", "reactor_1"],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"config": {
|
||||
"protocol_type": ["PumpTransferProtocol", "CleanProtocol"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 工作站 + Deck 引用
|
||||
|
||||
工作站节点中通过 `deck` 字段引用 Deck:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_station",
|
||||
"children": ["my_deck", "sub_device_1"],
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "my_deck",
|
||||
"_resource_type": "unilabos.resources.my_module.decks:MyDeck"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键约束:**
|
||||
- `_resource_child_name` 必须与 Deck 节点的 `id` 一致
|
||||
- `_resource_type` 为 Deck 类/工厂函数的完整 Python 路径
|
||||
|
||||
---
|
||||
|
||||
## 常见图模式
|
||||
|
||||
### 模式 A:单设备调试
|
||||
|
||||
最简形式,一个设备节点,无连接:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_device",
|
||||
"name": "my_device",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "motor.zdt_x42",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {"port": "/dev/ttyUSB0", "baudrate": 115200},
|
||||
"data": {"status": "idle"}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 B:Protocol 工作站(泵+阀+容器)
|
||||
|
||||
工作站配合泵、阀、容器和物理连接,用于协议编译:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "station", "name": "协议工作站",
|
||||
"class": "workstation", "type": "device", "parent": null,
|
||||
"children": ["pump", "valve", "flask_solvent", "reactor", "waste"],
|
||||
"config": {"protocol_type": ["PumpTransferProtocol"]}
|
||||
},
|
||||
{"id": "pump", "name": "转移泵", "class": "virtual_transfer_pump",
|
||||
"type": "device", "parent": "station",
|
||||
"config": {"port": "VIRTUAL", "max_volume": 25.0},
|
||||
"data": {"status": "Idle", "position": 0.0, "valve_position": "0"}},
|
||||
{"id": "valve", "name": "多通阀", "class": "virtual_multiway_valve",
|
||||
"type": "device", "parent": "station",
|
||||
"config": {"port": "VIRTUAL", "positions": 8}},
|
||||
{"id": "flask_solvent", "name": "溶剂瓶", "type": "container",
|
||||
"class": null, "parent": "station",
|
||||
"config": {"max_volume": 1000.0},
|
||||
"data": {"liquid": [{"liquid_type": "DMF", "liquid_volume": 500}]}},
|
||||
{"id": "reactor", "name": "反应器", "type": "container",
|
||||
"class": null, "parent": "station"},
|
||||
{"id": "waste", "name": "废液瓶", "type": "container",
|
||||
"class": null, "parent": "station"}
|
||||
],
|
||||
"links": [
|
||||
{"source": "pump", "target": "valve", "type": "fluid",
|
||||
"port": {"pump": "transferpump", "valve": "transferpump"}},
|
||||
{"source": "valve", "target": "flask_solvent", "type": "fluid",
|
||||
"port": {"valve": "1", "flask_solvent": "outlet"}},
|
||||
{"source": "valve", "target": "reactor", "type": "fluid",
|
||||
"port": {"valve": "2", "reactor": "inlet"}},
|
||||
{"source": "valve", "target": "waste", "type": "fluid",
|
||||
"port": {"valve": "3", "waste": "inlet"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 C:外部系统工作站 + Deck
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_station", "class": "reaction_station.bioyond",
|
||||
"parent": null, "children": ["bioyond_deck"],
|
||||
"config": {
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_KEY",
|
||||
"material_type_mappings": {},
|
||||
"warehouse_mapping": {}
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "bioyond_deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bioyond_deck", "class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"parent": "bioyond_station", "type": "deck",
|
||||
"config": {"type": "BIOYOND_PolymerReactionStation_Deck", "setup": true}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 D:通信代理(串口设备)
|
||||
|
||||
泵通过串口设备通信,使用 `communication` 类型的 link。加载时系统会自动将串口端口信息写入泵的 `config`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "station", "name": "工作站", "type": "device",
|
||||
"class": "workstation", "parent": null,
|
||||
"children": ["serial_1", "pump_1"]},
|
||||
{"id": "serial_1", "name": "串口", "type": "device",
|
||||
"class": "serial", "parent": "station",
|
||||
"config": {"port": "COM7", "baudrate": 9600}},
|
||||
{"id": "pump_1", "name": "注射泵", "type": "device",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08", "parent": "station"}
|
||||
],
|
||||
"links": [
|
||||
{"source": "pump_1", "target": "serial_1", "type": "communication",
|
||||
"port": {"pump_1": "port", "serial_1": "port"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 启动测试
|
||||
unilab -g unilabos/test/experiments/<name>.json --complete_registry
|
||||
|
||||
# 仅检查注册表
|
||||
python -m unilabos --check_mode --skip_env_check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
处理复杂图文件时,详见 [reference.md](reference.md):ResourceDict 完整字段 schema、Pose 标准化规则、Handle 验证机制、GraphML 格式支持、外部系统工作站完整 config 结构。
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误 | 原因 | 修复 |
|
||||
|------|------|------|
|
||||
| `class` 找不到 | 注册表中无此设备名 | 在 `unilabos/registry/devices/` 或 `resources/` 中搜索正确名称 |
|
||||
| children/parent 不一致 | 子节点 `parent` 与父节点 `children` 不匹配 | 确保双向一致 |
|
||||
| `_resource_child_name` 不匹配 | Deck 引用名与 Deck 节点 `id` 不同 | 保持一致 |
|
||||
| Link 端口错误 | `port` 中的 key 不是 source/target 的 `id` | key 必须是对应节点的 `id` |
|
||||
| 重复 UUID | 多个节点有相同 `uuid` | 删除或修改 UUID |
|
||||
|
||||
---
|
||||
|
||||
## 参考路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| 图文件目录 | `unilabos/test/experiments/` |
|
||||
| 协议测试站 | `unilabos/test/experiments/Protocol_Test_Station/` |
|
||||
| 图加载代码 | `unilabos/resources/graphio.py` |
|
||||
| 节点模型 | `unilabos/resources/resource_tracker.py` |
|
||||
| 设备注册表 | `unilabos/registry/devices/` |
|
||||
| 资源注册表 | `unilabos/registry/resources/` |
|
||||
| 用户文档 | `docs/user_guide/graph_files.md` |
|
||||
255
.cursor/skills/edit-experiment-graph/reference.md
Normal file
255
.cursor/skills/edit-experiment-graph/reference.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 实验图高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含 ResourceDict 完整 schema、Handle 验证、GraphML 格式、Pose 标准化规则和复杂图文件结构。Agent 在需要处理这些场景时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. ResourceDict 完整字段
|
||||
|
||||
`unilabos/resources/resource_tracker.py` 中定义的节点数据模型:
|
||||
|
||||
| 字段 | 类型 | 别名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | `str` | — | 节点唯一标识 |
|
||||
| `uuid` | `str` | — | 全局唯一标识 |
|
||||
| `name` | `str` | — | 显示名称 |
|
||||
| `description` | `str` | — | 描述(默认 `""` ) |
|
||||
| `resource_schema` | `Dict[str, Any]` | `schema` | 资源 schema |
|
||||
| `model` | `Dict[str, Any]` | — | 3D 模型信息 |
|
||||
| `icon` | `str` | — | 图标(默认 `""` ) |
|
||||
| `parent_uuid` | `Optional[str]` | — | 父节点 UUID |
|
||||
| `parent` | `Optional[ResourceDict]` | — | 父节点引用(序列化时 exclude) |
|
||||
| `type` | `Union[Literal["device"], str]` | — | 节点类型 |
|
||||
| `klass` | `str` | `class` | 注册表类名 |
|
||||
| `pose` | `ResourceDictPosition` | — | 位姿信息 |
|
||||
| `config` | `Dict[str, Any]` | — | 配置参数 |
|
||||
| `data` | `Dict[str, Any]` | — | 运行时数据 |
|
||||
| `extra` | `Dict[str, Any]` | — | 扩展数据 |
|
||||
|
||||
### Pose 完整结构(ResourceDictPosition)
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `size` | `{width, height, depth}` | `{0,0,0}` | 节点尺寸 |
|
||||
| `scale` | `{x, y, z}` | `{1,1,1}` | 缩放比例 |
|
||||
| `layout` | `"2d"/"x-y"/"z-y"/"x-z"` | `"x-y"` | 布局方向 |
|
||||
| `position` | `{x, y, z}` | `{0,0,0}` | 2D 位置 |
|
||||
| `position3d` | `{x, y, z}` | `{0,0,0}` | 3D 位置 |
|
||||
| `rotation` | `{x, y, z}` | `{0,0,0}` | 旋转角度 |
|
||||
| `cross_section_type` | `"rectangle"/"circle"/"rounded_rectangle"` | `"rectangle"` | 横截面形状 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Position / Pose 标准化规则
|
||||
|
||||
图文件中的 `position` 有多种写法,加载时自动标准化。
|
||||
|
||||
### 输入格式兼容
|
||||
|
||||
```json
|
||||
// 格式 A: 直接 {x, y, z}(最常用)
|
||||
"position": {"x": 100, "y": 200, "z": 0}
|
||||
|
||||
// 格式 B: 嵌套 position
|
||||
"position": {"position": {"x": 100, "y": 200, "z": 0}}
|
||||
|
||||
// 格式 C: 使用 pose 字段
|
||||
"pose": {"position": {"x": 100, "y": 200, "z": 0}}
|
||||
|
||||
// 格式 D: 顶层 x, y, z(无 position 字段)
|
||||
"x": 100, "y": 200, "z": 0
|
||||
```
|
||||
|
||||
### 标准化流程
|
||||
|
||||
1. **graphio.py `canonicalize_nodes_data`**:若 `position` 不是 dict,从节点顶层提取 `x/y/z` 填入 `pose.position`
|
||||
2. **resource_tracker.py `get_resource_instance_from_dict`**:若 `position.x` 存在(旧格式),转为 `{"position": {"x":..., "y":..., "z":...}}`
|
||||
3. `pose.size` 从 `config.size_x/size_y/size_z` 自动填充
|
||||
|
||||
---
|
||||
|
||||
## 3. Handle 验证
|
||||
|
||||
启动时系统验证 link 中的 `sourceHandle` / `targetHandle` 是否在注册表的 `handles` 中定义。
|
||||
|
||||
```python
|
||||
# unilabos/app/main.py (约 449-481 行)
|
||||
source_handler_keys = [
|
||||
h["handler_key"] for h in materials[source_node.klass]["handles"]
|
||||
if h["io_type"] == "source"
|
||||
]
|
||||
target_handler_keys = [
|
||||
h["handler_key"] for h in materials[target_node.klass]["handles"]
|
||||
if h["io_type"] == "target"
|
||||
]
|
||||
if source_handle not in source_handler_keys:
|
||||
print_status(f"节点 {source_node.id} 的source端点 {source_handle} 不存在", "error")
|
||||
resource_edge_info.pop(...) # 移除非法 link
|
||||
```
|
||||
|
||||
**Handle 定义在注册表 YAML 中:**
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
handles:
|
||||
- handler_key: access
|
||||
io_type: target
|
||||
data_type: fluid
|
||||
side: NORTH
|
||||
label: access
|
||||
```
|
||||
|
||||
> 大多数简单设备不定义 handles,此验证仅对有 `sourceHandle`/`targetHandle` 的 link 生效。
|
||||
|
||||
---
|
||||
|
||||
## 4. GraphML 格式支持
|
||||
|
||||
除 JSON 外,系统也支持 GraphML 格式(`unilabos/resources/graphio.py::read_graphml`)。
|
||||
|
||||
### 与 JSON 的关键差异
|
||||
|
||||
| 特性 | JSON | GraphML |
|
||||
|------|------|---------|
|
||||
| 父子关系 | `parent`/`children` 字段 | `::` 分隔的节点 ID(如 `station::pump_1`) |
|
||||
| 加载后 | 直接解析 | 先 `nx.read_graphml` 再转 JSON 格式 |
|
||||
| 输出 | 不生成副本 | 自动生成等价的 `.json` 文件 |
|
||||
|
||||
### GraphML 转换流程
|
||||
|
||||
```
|
||||
nx.read_graphml(file)
|
||||
↓ 用 label 重映射节点名
|
||||
↓ 从 "::" 推断 parent_relation
|
||||
nx.relabel_nodes + nx.node_link_data
|
||||
↓ canonicalize_nodes_data + canonicalize_links_ports
|
||||
↓ 写出等价 JSON 文件
|
||||
physical_setup_graph + handle_communications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 复杂图文件结构示例
|
||||
|
||||
### 外部系统工作站完整 config
|
||||
|
||||
以 `reaction_station_bioyond.json` 为例,工作站 `config` 中的关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://172.21.103.36:45388",
|
||||
|
||||
"workflow_mappings": {
|
||||
"scheduler_start": {"workflow": "start", "params": {}},
|
||||
"create_order": {"workflow": "create_order", "params": {}}
|
||||
},
|
||||
|
||||
"material_type_mappings": {
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "type-uuid-here"],
|
||||
"BIOYOND_PolymerStation_1BottleCarrier": ["试剂瓶", "type-uuid-here"]
|
||||
},
|
||||
|
||||
"warehouse_mapping": {
|
||||
"堆栈1左": {
|
||||
"uuid": "warehouse-uuid-here",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-1",
|
||||
"A02": "site-uuid-2"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"http_service_config": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 45399,
|
||||
"routes": ["/callback/workflow", "/callback/material"]
|
||||
},
|
||||
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
},
|
||||
|
||||
"size_x": 2700.0,
|
||||
"size_y": 1080.0,
|
||||
"size_z": 2500.0,
|
||||
"protocol_type": [],
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 子设备 Reactor 节点
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "reactor_1",
|
||||
"name": "reactor_1",
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "device",
|
||||
"class": "bioyond_reactor",
|
||||
"position": {"x": 1150, "y": 300, "z": 0},
|
||||
"config": {
|
||||
"reactor_index": 0,
|
||||
"bioyond_workflow_key": "reactor_1"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Deck 节点
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "Bioyond_Deck",
|
||||
"name": "Bioyond_Deck",
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "deck",
|
||||
"class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"type": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Link 端口标准化
|
||||
|
||||
`graphio.py::canonicalize_links_ports` 处理 `port` 字段的多种格式:
|
||||
|
||||
```python
|
||||
# 输入: 字符串格式 "(A,B)"
|
||||
"port": "(pump_1, valve_1)"
|
||||
# 输出: 字典格式
|
||||
"port": {"source_id": "pump_1", "target_id": "valve_1"}
|
||||
|
||||
# 输入: 已是字典
|
||||
"port": {"pump_1": "port", "serial_1": "port"}
|
||||
# 保持不变
|
||||
|
||||
# 输入: 无 port 字段
|
||||
# 自动补充空 port
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| ResourceDict 模型 | `unilabos/resources/resource_tracker.py` |
|
||||
| 图加载 + 标准化 | `unilabos/resources/graphio.py` |
|
||||
| Handle 验证 | `unilabos/app/main.py` (449-481 行) |
|
||||
| 反应站图文件 | `unilabos/test/experiments/reaction_station_bioyond.json` |
|
||||
| 配液站图文件 | `unilabos/test/experiments/dispensing_station_bioyond.json` |
|
||||
| 用户文档 | `docs/user_guide/graph_files.md` |
|
||||
212
.cursorignore
212
.cursorignore
@@ -1,26 +1,188 @@
|
||||
.conda
|
||||
# .github
|
||||
.idea
|
||||
# .vscode
|
||||
output
|
||||
pylabrobot_repo
|
||||
recipes
|
||||
scripts
|
||||
service
|
||||
temp
|
||||
# unilabos/test
|
||||
# unilabos/app/web
|
||||
unilabos/device_mesh
|
||||
unilabos_data
|
||||
unilabos_msgs
|
||||
unilabos.egg-info
|
||||
CONTRIBUTORS
|
||||
# LICENSE
|
||||
MANIFEST.in
|
||||
# ============================================================
|
||||
# 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
|
||||
# README.md
|
||||
# README_zh.md
|
||||
setup.py
|
||||
setup.cfg
|
||||
.gitattrubutes
|
||||
**/__pycache__
|
||||
|
||||
# ==================== 其他 ====================
|
||||
# Catkin
|
||||
CATKIN_IGNORE
|
||||
|
||||
# Eclipse/Qt
|
||||
.project
|
||||
.cproject
|
||||
CMakeLists.txt.user
|
||||
*.user
|
||||
qtcreator-*
|
||||
|
||||
11
.github/copilot-instructions.md
vendored
Normal file
11
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
## 设备接入
|
||||
|
||||
当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`。
|
||||
该指南包含完整的模板和已有设备接口参考。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 动作方法的参数名是接口契约,不可重命名
|
||||
- `status` 字符串必须与同类已有设备一致
|
||||
- `self.data` 必须在 `__init__` 中预填充所有属性字段
|
||||
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()`
|
||||
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "msgcenterpy-team"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ temp/
|
||||
output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
.cursorignore
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
21
AGENTS.md
Normal file
21
AGENTS.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Uni-Lab-OS AI Agent 指南
|
||||
|
||||
## 设备接入
|
||||
|
||||
当用户要求添加/接入新设备时,读取 `docs/ai_guides/add_device.md` 并按其流程执行。
|
||||
该指南完全自包含,包含物模型模板、现有设备接口快照、常见错误和验证清单。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 动作方法的参数名是接口契约,不可重命名(如 `volume` 不能改为 `volume_ml`)
|
||||
- `status` 字符串必须与同类已有设备一致(如 `"Idle"` 不能改为 `"就绪"`)
|
||||
- `self.data` 必须在 `__init__` 中预填充所有属性字段
|
||||
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
|
||||
## 项目结构
|
||||
|
||||
- 设备驱动:`unilabos/devices/<category>/<device_name>.py`
|
||||
- 设备注册表:`unilabos/registry/devices/<device_name>.yaml`
|
||||
- 实验图文件:`unilabos/test/experiments/*.json`
|
||||
- 人类开发文档:`docs/developer_guide/`
|
||||
- AI 专用指南:`docs/ai_guides/`
|
||||
14
CLAUDE.md
Normal file
14
CLAUDE.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Uni-Lab-OS
|
||||
|
||||
## 设备接入
|
||||
|
||||
读取 `docs/ai_guides/add_device.md` 获取完整的自包含指南。
|
||||
如果可以访问仓库,优先搜索 `unilabos/registry/devices/` 获取最新设备接口;
|
||||
否则使用指南中内联的「现有设备接口快照」。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 动作方法的参数名是接口契约,不可重命名(如 `volume` 不能改为 `volume_ml`)
|
||||
- `status` 字符串必须与同类已有设备一致(如 `"Idle"` 不能改为 `"就绪"`)
|
||||
- `self.data` 必须在 `__init__` 中预填充所有属性字段
|
||||
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
1100
docs/ai_guides/add_device.md
Normal file
1100
docs/ai_guides/add_device.md
Normal file
File diff suppressed because it is too large
Load Diff
344
docs/ai_guides/agent_prompt_template.md
Normal file
344
docs/ai_guides/agent_prompt_template.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Uni-Lab-OS 设备接入 Agent — 提示词模板
|
||||
|
||||
> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。
|
||||
> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。
|
||||
|
||||
---
|
||||
|
||||
## 系统提示词模板
|
||||
|
||||
以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。
|
||||
|
||||
---
|
||||
|
||||
### 开始复制 ↓
|
||||
|
||||
```
|
||||
你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。
|
||||
|
||||
你能做的事:
|
||||
- 根据用户描述,生成完整的设备驱动代码(Python)、注册表(YAML)和实验图文件(JSON)
|
||||
- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式
|
||||
- 诊断已有驱动代码的接口对齐问题
|
||||
|
||||
你不能做的事:
|
||||
- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取)
|
||||
- 替代真实硬件联调测试
|
||||
|
||||
## 知识来源
|
||||
|
||||
{{KNOWLEDGE_LOADING}}
|
||||
|
||||
## 工作流程
|
||||
|
||||
当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。
|
||||
|
||||
### 阶段 1:设备画像(交互)
|
||||
|
||||
向用户收集以下三个信息,可以一次性提问:
|
||||
|
||||
1. **设备类别** — 属于以下哪一种?
|
||||
- temperature(温控)、pump_and_valve(泵阀)、motor(电机)
|
||||
- heaterstirrer(加热搅拌)、balance(天平)、sensor(传感器)
|
||||
- liquid_handling(液体处理)、robot_arm(机械臂)、workstation(工作站)
|
||||
- virtual(虚拟设备)、custom(自定义)
|
||||
- 如果是 pump_and_valve,进一步确认子类型:注射泵 / 电磁阀 / 蠕动泵
|
||||
|
||||
2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b)
|
||||
|
||||
3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟)
|
||||
|
||||
⏸️ **暂停:等待用户回答后继续**
|
||||
|
||||
### 阶段 2:指令协议收集(交互)
|
||||
|
||||
根据上一步确定的通信协议,引导用户提供指令信息:
|
||||
|
||||
- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑
|
||||
- 如果用户有 **协议文档**:请用户提供文档(PDF/图片/文本),你从中解析指令格式
|
||||
- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令
|
||||
- 如果是 **标准协议**(Modbus 寄存器表、SCPI):请用户提供寄存器/指令映射
|
||||
- 如果是 **虚拟设备**:跳过此阶段
|
||||
|
||||
⏸️ **暂停:确认已获取足够的指令协议信息**
|
||||
|
||||
### 阶段 3:确认摘要
|
||||
|
||||
在开始生成代码前,向用户展示你的理解摘要:
|
||||
|
||||
```
|
||||
设备接入摘要:
|
||||
- 设备名称:<name>
|
||||
- 设备类别:<category>(<subtype>)
|
||||
- 通信协议:<protocol>
|
||||
- 指令来源:<source>
|
||||
- 将要实现的属性:<list>
|
||||
- 将要实现的动作:<list>
|
||||
- 同类已有设备:<existing>(将对齐其接口)
|
||||
```
|
||||
|
||||
⏸️ **暂停:用户确认"没问题"后再生成代码**
|
||||
|
||||
### 阶段 4:自动生成(无需暂停)
|
||||
|
||||
按以下顺序自动执行:
|
||||
|
||||
1. **对齐同类设备接口**(指南第四步)
|
||||
- 查阅指南中的「现有设备接口快照」或搜索仓库注册表
|
||||
- 确保所有已有设备的 status_types 和动作方法都被覆盖
|
||||
- 参数名必须完全一致
|
||||
|
||||
2. **生成驱动代码** — `unilabos/devices/<category>/<name>.py`
|
||||
|
||||
3. **生成注册表** — `unilabos/registry/devices/<name>.yaml`(最小配置)
|
||||
|
||||
4. **生成图文件** — `unilabos/test/experiments/graph_example_<name>.json`
|
||||
|
||||
### 阶段 5:验证输出
|
||||
|
||||
生成完成后,逐项检查对齐验证清单并展示结果:
|
||||
|
||||
```
|
||||
对齐验证清单:
|
||||
- [x] 所有动作方法的参数名与已有设备完全一致
|
||||
- [x] status 属性返回的字符串值与已有设备一致
|
||||
- [x] 已有设备的所有 status_types 字段都有对应 @property
|
||||
- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法
|
||||
- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值
|
||||
- [x] 串口/二进制协议的响应解析先定位帧起始标记
|
||||
```
|
||||
|
||||
如果有未通过的项,主动修复后再展示。
|
||||
|
||||
## 硬约束(违反任何一条都会导致设备接入失败)
|
||||
|
||||
1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity)是接口契约,框架通过参数名分派调用。绝不能加后缀(如 volume_ml)、改名(如 speed_ml_s)。单位写在 docstring 中。
|
||||
|
||||
2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。
|
||||
|
||||
3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。
|
||||
|
||||
4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。
|
||||
|
||||
5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE),禁止用硬编码索引直接解析。
|
||||
|
||||
6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。
|
||||
|
||||
7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位(mL、°C、RPM),驱动内部负责转换到硬件原始值(步数、Hz、寄存器值)。
|
||||
|
||||
## 代码骨架参考
|
||||
|
||||
所有设备驱动遵循以下结构:
|
||||
|
||||
```python
|
||||
import logging
|
||||
import time as time_module
|
||||
from typing import Dict, Any
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
except ImportError:
|
||||
BaseROS2DeviceNode = None
|
||||
|
||||
class MyDevice:
|
||||
_ros_node: "BaseROS2DeviceNode"
|
||||
|
||||
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
|
||||
if device_id is None and 'id' in kwargs:
|
||||
device_id = kwargs.pop('id')
|
||||
if config is None and 'config' in kwargs:
|
||||
config = kwargs.pop('config')
|
||||
self.device_id = device_id or "unknown_device"
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
self.data = {
|
||||
"status": "Idle",
|
||||
# 所有 @property 的键都必须在此预填充
|
||||
}
|
||||
|
||||
def post_init(self, ros_node: "BaseROS2DeviceNode"):
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
self.data["status"] = "Idle"
|
||||
return True
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
self.data["status"] = "Offline"
|
||||
return True
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Idle")
|
||||
```
|
||||
|
||||
## 注册表最小配置
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
class:
|
||||
module: unilabos.devices.<category>.<file>:MyDevice
|
||||
type: python
|
||||
```
|
||||
|
||||
启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。
|
||||
|
||||
## 图文件模板
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_device_1",
|
||||
"name": "设备名称",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "my_device",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 现有设备接口快照(对齐用)
|
||||
|
||||
对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本:
|
||||
https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/
|
||||
|
||||
### pump_and_valve — 注射泵
|
||||
|
||||
已有设备:syringe_pump_with_valve.runze.SY03B-T06
|
||||
|
||||
属性:status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String)
|
||||
|
||||
方法签名(参数名不可改):
|
||||
- initialize()
|
||||
- set_valve_position(position)
|
||||
- set_position(position: float, max_velocity: float = None)
|
||||
- pull_plunger(volume: float)
|
||||
- push_plunger(volume: float)
|
||||
- set_max_velocity(velocity: float)
|
||||
- set_velocity_grade(velocity)
|
||||
- stop_operation()
|
||||
|
||||
### pump_and_valve — 电磁阀
|
||||
|
||||
属性:status(str), valve_position(str)
|
||||
方法:open(), close(), set_valve_position(position), is_open(), is_closed()
|
||||
|
||||
### temperature
|
||||
|
||||
属性:status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C)
|
||||
|
||||
### motor
|
||||
|
||||
属性:status(str), position(int)
|
||||
|
||||
### sensor
|
||||
|
||||
属性:level(bool), rssi(int)
|
||||
```
|
||||
|
||||
### 结束复制 ↑
|
||||
|
||||
---
|
||||
|
||||
## `{{KNOWLEDGE_LOADING}}` 变量替换
|
||||
|
||||
根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容:
|
||||
|
||||
### 方案 A:有知识库(Custom GPT / Claude Project)
|
||||
|
||||
```
|
||||
你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。
|
||||
执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
|
||||
本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。
|
||||
```
|
||||
|
||||
### 方案 B:有联网能力
|
||||
|
||||
```
|
||||
执行工作流前,从以下 URL 获取完整的设备接入指南:
|
||||
https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md
|
||||
|
||||
该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
|
||||
如果无法访问 URL,使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。
|
||||
```
|
||||
|
||||
### 方案 C:无知识库、无联网
|
||||
|
||||
```
|
||||
完整的设备接入指南需要用户在对话中提供。
|
||||
如果用户未主动提供,请在阶段 1 开始前询问:
|
||||
"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。"
|
||||
|
||||
本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。
|
||||
但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 各平台配置指南
|
||||
|
||||
### OpenAI Custom GPT
|
||||
|
||||
1. 进入 https://chat.openai.com/gpts/editor
|
||||
2. **Name**:Uni-Lab-OS 设备接入助手
|
||||
3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。
|
||||
4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
|
||||
5. **Knowledge**:上传 `docs/ai_guides/add_device.md`
|
||||
6. **Capabilities**:开启 Code Interpreter(用于代码验证)
|
||||
7. **Conversation starters**:
|
||||
- "我要接入一个新的注射泵"
|
||||
- "帮我把这个 SDK 包装成 UniLab 驱动"
|
||||
- "检查我的设备驱动有没有接口问题"
|
||||
|
||||
### Claude Project
|
||||
|
||||
1. 创建新 Project
|
||||
2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
|
||||
3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md`
|
||||
|
||||
### API Agent(LangChain / AutoGen / 自建框架)
|
||||
|
||||
```python
|
||||
system_prompt = """
|
||||
<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B>
|
||||
"""
|
||||
|
||||
# 如果框架支持工具调用,可注册以下工具:
|
||||
tools = [
|
||||
{
|
||||
"name": "fetch_device_guide",
|
||||
"description": "获取最新的 Uni-Lab-OS 设备接入指南",
|
||||
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md"
|
||||
},
|
||||
{
|
||||
"name": "fetch_registry",
|
||||
"description": "获取最新的设备注册表",
|
||||
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml"
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Cursor Agent Mode
|
||||
|
||||
无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力(Grep 搜索注册表、AskQuestion 收集信息等)。
|
||||
|
||||
### 纯网页对话(ChatGPT / Claude 无 Project)
|
||||
|
||||
1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C)
|
||||
2. 第二条消息上传或粘贴 `add_device.md`
|
||||
3. 第三条消息开始描述设备
|
||||
|
||||
---
|
||||
|
||||
## 维护说明
|
||||
|
||||
- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分
|
||||
- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分
|
||||
- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分
|
||||
- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.17
|
||||
version: 0.10.18
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.17"
|
||||
version: "0.10.18"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.17',
|
||||
version='0.10.18',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Liquid handling 集成测试
|
||||
|
||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
||||
|
||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
||||
|
||||
```bash
|
||||
export UNILAB_SIM_TEST=1
|
||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
||||
```
|
||||
|
||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyContainer:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyContainer({self.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyTipSpot({self.name})"
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
||||
|
||||
def __init__(self, channel_num: int = 8):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"aspirate",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"dispense",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
||||
|
||||
async def touch_tip(self, targets):
|
||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
||||
self.calls.append(("touch_tip", {"targets": targets}))
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_basic_calls():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 2, 3],
|
||||
dis_vols=[4, 5, 6],
|
||||
mix_times=None, # 应该仍能执行(不 mix)
|
||||
)
|
||||
)
|
||||
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
||||
|
||||
# 每次 aspirate/dispense 都是单孔列表
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [sources[0]]
|
||||
assert aspirates[0]["vols"] == [1.0]
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[2]["resources"] == [targets[2]]
|
||||
assert dispenses[2]["vols"] == [6.0]
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(16))
|
||||
|
||||
source = DummyContainer("S0")
|
||||
target = DummyContainer("T0")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5],
|
||||
dis_vols=[5],
|
||||
mix_stage="before",
|
||||
mix_times=1,
|
||||
mix_vol=3,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 2
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [3]
|
||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
||||
assert transfer_payload["resources"] == [source]
|
||||
assert mix_idx < transfer_idx
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_groups_by_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = list(range(1, 17))
|
||||
dis_vols = list(range(101, 117))
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0, # 触发逻辑但不 mix
|
||||
)
|
||||
)
|
||||
|
||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == 2
|
||||
assert len(dispenses) == 2
|
||||
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
||||
assert dispenses[1]["resources"] == targets[8:16]
|
||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
||||
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(512))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = [i + 1 for i in range(16)]
|
||||
dis_vols = [200 + i for i in range(16)]
|
||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
||||
offsets = [f"offset_{i}" for i in range(16)]
|
||||
liquid_heights = [i * 0.5 for i in range(16)]
|
||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_heights,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
|
||||
for batch_idx in range(2):
|
||||
start = batch_idx * 8
|
||||
end = start + 8
|
||||
asp_call = aspirates[batch_idx]
|
||||
dis_call = dispenses[batch_idx]
|
||||
assert asp_call["resources"] == sources[start:end]
|
||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
||||
assert asp_call["offsets"] == offsets[start:end]
|
||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
||||
assert dis_call["offsets"] == offsets[start:end]
|
||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(1024))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
||||
asp_vols = [i + 1 for i in range(32)]
|
||||
dis_vols = [300 + i for i in range(32)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(pick_calls) == 4
|
||||
assert len(aspirates) == len(dispenses) == 4
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[-1]["resources"] == sources[24:32]
|
||||
assert dispenses[0]["resources"] == targets[0:8]
|
||||
assert dispenses[-1]["resources"] == targets[24:32]
|
||||
|
||||
|
||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
dis_vols = [10, 20, 30] # sum=60
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 1
|
||||
assert aspirates[0]["resources"] == [source]
|
||||
assert aspirates[0]["vols"] == [60.0]
|
||||
assert aspirates[0]["use_channels"] == [0]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_one_to_many_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
||||
dis_vols = [i + 1 for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [source] * 8
|
||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[0]["resources"] == targets
|
||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
||||
assert all(d["resources"] == [target] for d in dispenses)
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
target = DummyContainer("T")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5, 6],
|
||||
dis_vols=1,
|
||||
mix_stage="before",
|
||||
mix_times=2,
|
||||
mix_vol=4,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 1
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [4]
|
||||
# 第一個 mix 之後會真正開始吸 source
|
||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
dis_vols = [1, 2, 3]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols, # 比例模式
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [10 + i for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert aspirates[0]["resources"] == sources
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
||||
assert dispenses[0]["resources"] == [target] * 8
|
||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
||||
|
||||
|
||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_mix_single_target_produces_matching_cycles():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
target = DummyContainer("T_mix")
|
||||
|
||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
assert all(call["resources"] == [target] for call in aspirates)
|
||||
assert all(call["vols"] == [5] for call in aspirates)
|
||||
assert all(call["resources"] == [target] for call in dispenses)
|
||||
assert all(call["vols"] == [5] for call in dispenses)
|
||||
|
||||
|
||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
||||
offsets = ["left", "right"]
|
||||
heights = [0.1, 0.2]
|
||||
rates = [0.5, 1.0]
|
||||
|
||||
run(
|
||||
lh.mix(
|
||||
targets=targets,
|
||||
mix_time=1,
|
||||
mix_vol=3,
|
||||
offsets=offsets,
|
||||
height_to_bottom=heights,
|
||||
mix_rate=rates,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 2
|
||||
assert aspirates[0]["resources"] == [targets[0]]
|
||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
||||
assert aspirates[1]["resources"] == [targets[1]]
|
||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.17"
|
||||
__version__ = "0.10.18"
|
||||
|
||||
6
unilabos/__main__.py
Normal file
6
unilabos/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Entry point for `python -m unilabos`."""
|
||||
|
||||
from unilabos.app.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
|
||||
@@ -23,7 +23,7 @@ from typing import Optional, Dict, Any, List
|
||||
from urllib.parse import urlparse
|
||||
from enum import Enum
|
||||
|
||||
from jedi.inference.gradual.typing import TypedDict
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.app.model import JobAddReq
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
@@ -164,7 +164,7 @@ class DeviceActionManager:
|
||||
job_info.set_ready_timeout(10) # 设置10秒超时
|
||||
self.active_jobs[device_key] = job_info
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||
return True
|
||||
|
||||
def start_job(self, job_id: str) -> bool:
|
||||
@@ -231,8 +231,9 @@ class DeviceActionManager:
|
||||
job_info.update_timestamp()
|
||||
# 从all_jobs中移除已结束的job
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||
# job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
# logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||
pass
|
||||
else:
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
||||
@@ -248,7 +249,7 @@ class DeviceActionManager:
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||
return next_job
|
||||
|
||||
return None
|
||||
@@ -302,7 +303,7 @@ class DeviceActionManager:
|
||||
# 从all_jobs中移除
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
|
||||
# 启动下一个任务
|
||||
if device_key in self.device_queues and self.device_queues[device_key]:
|
||||
@@ -315,7 +316,7 @@ class DeviceActionManager:
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
return True
|
||||
|
||||
# 如果是排队中的任务
|
||||
@@ -329,7 +330,7 @@ class DeviceActionManager:
|
||||
job_log = format_job_log(
|
||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||
return True
|
||||
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
@@ -533,8 +534,12 @@ class MessageProcessor:
|
||||
await self._process_message(message_type, message_data)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}")
|
||||
logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}")
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
@@ -604,7 +609,7 @@ class MessageProcessor:
|
||||
|
||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
||||
"""处理收到的消息"""
|
||||
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||
logger.trace(f"[MessageProcessor] Processing message: {message_type}")
|
||||
|
||||
try:
|
||||
if message_type == "pong":
|
||||
@@ -698,13 +703,13 @@ class MessageProcessor:
|
||||
await self._send_action_state_response(
|
||||
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
||||
)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||
else:
|
||||
# 需要排队
|
||||
await self._send_action_state_response(
|
||||
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
||||
)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} queued")
|
||||
logger.trace(f"[MessageProcessor] Job {job_log} queued")
|
||||
|
||||
# 通知QueueProcessor有新的队列更新
|
||||
if self.queue_processor:
|
||||
@@ -911,9 +916,7 @@ class MessageProcessor:
|
||||
device_action_groups[key_add] = []
|
||||
device_action_groups[key_add].append(item["uuid"])
|
||||
|
||||
logger.info(
|
||||
f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||
)
|
||||
logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}")
|
||||
else:
|
||||
# 正常update
|
||||
key = (device_id, "update")
|
||||
@@ -927,7 +930,9 @@ class MessageProcessor:
|
||||
device_action_groups[key] = []
|
||||
device_action_groups[key].append(item["uuid"])
|
||||
|
||||
logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||
logger.trace(
|
||||
f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}"
|
||||
)
|
||||
|
||||
# 为每个(device_id, action)创建独立的更新线程
|
||||
for (device_id, actual_action), items in device_action_groups.items():
|
||||
@@ -966,42 +971,44 @@ class MessageProcessor:
|
||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理重启请求
|
||||
|
||||
|
||||
当LabGo发送request_restart时,执行清理并触发重启
|
||||
"""
|
||||
reason = data.get("reason", "unknown")
|
||||
delay = data.get("delay", 2) # 默认延迟2秒
|
||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||
|
||||
|
||||
# 发送确认消息
|
||||
if self.websocket_client:
|
||||
await self.websocket_client.send_message({
|
||||
"action": "restart_acknowledged",
|
||||
"data": {"reason": reason, "delay": delay}
|
||||
})
|
||||
|
||||
await self.websocket_client.send_message(
|
||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||
)
|
||||
|
||||
# 设置全局重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
main_module._restart_requested = True
|
||||
main_module._restart_reason = reason
|
||||
|
||||
|
||||
# 延迟后执行清理
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
# 在新线程中执行清理,避免阻塞当前事件循环
|
||||
def do_cleanup():
|
||||
import time
|
||||
|
||||
time.sleep(0.5) # 给当前消息处理完成的时间
|
||||
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
|
||||
try:
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
|
||||
if cleanup_for_restart():
|
||||
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
|
||||
else:
|
||||
logger.error("[MessageProcessor] Cleanup failed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
|
||||
|
||||
|
||||
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
|
||||
cleanup_thread.start()
|
||||
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
|
||||
@@ -1197,7 +1204,7 @@ class QueueProcessor:
|
||||
success = self.message_processor.send_message(message)
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
if success:
|
||||
logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||
logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||
else:
|
||||
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
||||
|
||||
@@ -1220,7 +1227,7 @@ class QueueProcessor:
|
||||
job_info.action_name,
|
||||
)
|
||||
|
||||
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
|
||||
# 结束任务,获取下一个可执行的任务
|
||||
next_job = self.device_manager.end_job(job_id)
|
||||
@@ -1240,8 +1247,8 @@ class QueueProcessor:
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||
logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||
|
||||
# 立即触发下一轮状态检查
|
||||
self.notify_queue_update()
|
||||
@@ -1383,7 +1390,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||
|
||||
logger.info(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {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)
|
||||
@@ -1444,15 +1451,17 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
# 收集设备信息
|
||||
devices = []
|
||||
machine_name = BasicConfig.machine_name
|
||||
|
||||
|
||||
try:
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node:
|
||||
# 获取设备信息
|
||||
for device_id, namespace in host_node.devices_names.items():
|
||||
device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
|
||||
device_key = (
|
||||
f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
|
||||
)
|
||||
is_online = device_key in host_node._online_devices
|
||||
|
||||
|
||||
# 获取设备的动作信息
|
||||
actions = {}
|
||||
for action_id, client in host_node._action_clients.items():
|
||||
@@ -1463,16 +1472,18 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
"action_path": action_id,
|
||||
"action_type": str(type(client).__name__),
|
||||
}
|
||||
|
||||
devices.append({
|
||||
"device_id": device_id,
|
||||
"namespace": namespace,
|
||||
"device_key": device_key,
|
||||
"is_online": is_online,
|
||||
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
|
||||
"actions": actions,
|
||||
})
|
||||
|
||||
|
||||
devices.append(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"namespace": namespace,
|
||||
"device_key": device_key,
|
||||
"is_online": is_online,
|
||||
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
|
||||
"actions": actions,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Error collecting device info: {e}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ class Base(ABC):
|
||||
self._type = typ
|
||||
self._data_type = data_type
|
||||
self._node: Optional[Node] = None
|
||||
|
||||
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
@@ -66,7 +66,7 @@ class Base(ABC):
|
||||
# 直接以字符串形式处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||
# 提取括号内的内容
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
@@ -116,16 +116,16 @@ class Base(ABC):
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""读取节点值,返回(值, 是否出错)"""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def write(self, value: Any) -> bool:
|
||||
"""写入节点值,返回是否出错"""
|
||||
pass
|
||||
|
||||
|
||||
@property
|
||||
def type(self) -> NodeType:
|
||||
return self._type
|
||||
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
return self._node_id
|
||||
@@ -210,15 +210,15 @@ class Method(Base):
|
||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||
self._parent_node_id = parent_node_id
|
||||
self._parent_node = None
|
||||
|
||||
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||
import re
|
||||
|
||||
|
||||
nid = self._parent_node_id
|
||||
|
||||
|
||||
# 如果已经是 NodeId 对象,直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
@@ -227,16 +227,16 @@ class Method(Base):
|
||||
return self._parent_node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 字符串处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
|
||||
# 常见短格式
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
@@ -271,7 +271,7 @@ class Method(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""方法节点不支持写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def call(self, *args) -> Tuple[Any, bool]:
|
||||
"""调用方法,返回(返回值, 是否出错)"""
|
||||
try:
|
||||
@@ -285,7 +285,7 @@ class Method(Base):
|
||||
class Object(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str):
|
||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""对象节点不支持直接读取操作"""
|
||||
return None, True
|
||||
@@ -293,7 +293,7 @@ class Object(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""对象节点不支持直接写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def get_children(self) -> Tuple[List[Node], bool]:
|
||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||
try:
|
||||
@@ -301,4 +301,4 @@ class Object(Base):
|
||||
return children, False
|
||||
except Exception as e:
|
||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||
return [], True
|
||||
return [], True
|
||||
|
||||
@@ -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
|
||||
@@ -208,8 +220,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
if spread == "":
|
||||
spread = "wide"
|
||||
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.aspirate(
|
||||
resources,
|
||||
@@ -222,46 +233,23 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
try:
|
||||
await super().aspirate(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
except ValueError as e:
|
||||
if "Resource is too small to space channels" in str(e) and spread != "custom":
|
||||
await super().aspirate(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread="custom",
|
||||
**backend_kwargs,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
await super().aspirate(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
# 处理 use_channels 为 None 的情况(通常用于单通道操作)
|
||||
if use_channels is None:
|
||||
# 对于单通道操作,推断通道为 [0]
|
||||
channels_to_use = [0] * len(resources)
|
||||
else:
|
||||
channels_to_use = use_channels
|
||||
|
||||
for resource, volume, channel in zip(resources, vols, channels_to_use):
|
||||
sample_uuid_value = getattr(resource, "unilabos_extra", {}).get(EXTRA_SAMPLE_UUID, None)
|
||||
res_samples.append({"name": resource.name, "sample_uuid": sample_uuid_value})
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
sample_uuid_value = resource.unilabos_extra.get(EXTRA_SAMPLE_UUID, None)
|
||||
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: sample_uuid_value})
|
||||
res_volumes.append(volume)
|
||||
self.pending_liquids_dict[channel] = {
|
||||
EXTRA_SAMPLE_UUID: sample_uuid_value,
|
||||
@@ -281,8 +269,6 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
) -> SimpleReturn:
|
||||
if spread == "":
|
||||
spread = "wide"
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.dispense(
|
||||
resources,
|
||||
@@ -295,33 +281,16 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
try:
|
||||
await super().dispense(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
except ValueError as e:
|
||||
if "Resource is too small to space channels" in str(e) and spread != "custom":
|
||||
await super().dispense(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
"custom",
|
||||
**backend_kwargs,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
await super().dispense(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
**backend_kwargs,
|
||||
)
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
@@ -891,7 +860,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
for _ in range(len(sources)):
|
||||
tip = []
|
||||
for __ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
await self.aspirate(
|
||||
resources=[sources[_]],
|
||||
@@ -931,7 +900,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
for i in range(0, len(sources), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
current_targets = waste_liquid[i : i + 8]
|
||||
current_reagent_sources = sources[i : i + 8]
|
||||
@@ -1025,7 +994,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
for _ in range(len(targets)):
|
||||
tip = []
|
||||
for x in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.aspirate(
|
||||
@@ -1077,7 +1046,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
for i in range(0, len(targets), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
current_targets = targets[i : i + 8]
|
||||
current_reagent_sources = reagent_sources[i : i + 8]
|
||||
@@ -1202,19 +1171,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
Number of mix cycles. If *None* (default) no mixing occurs regardless of
|
||||
mix_stage.
|
||||
"""
|
||||
num_sources = len(sources)
|
||||
num_targets = len(targets)
|
||||
len_asp_vols = len(asp_vols)
|
||||
len_dis_vols = len(dis_vols)
|
||||
|
||||
# 确保 use_channels 有默认值
|
||||
if use_channels is None:
|
||||
# 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7)
|
||||
use_channels = list(range(self.channel_num)) if self.channel_num == 8 else [0]
|
||||
elif len(use_channels) == 8:
|
||||
if self.channel_num != 8:
|
||||
raise ValueError(f"if channel_num is 8, use_channels length must be 8, but got {len(use_channels)}")
|
||||
if num_sources%8 != 0 or num_targets%8 != 0 or len_asp_vols%8 != 0 or len_dis_vols%8 != 0:
|
||||
raise ValueError(f"if channel_num is 8, sources, targets, asp_vols, and dis_vols length must be divisible by 8, but got {num_sources}, {num_targets}, {len_asp_vols}, and {len_dis_vols}")
|
||||
use_channels = list(range(self.channel_num)) if self.channel_num > 0 else [0]
|
||||
|
||||
if is_96_well:
|
||||
pass # This mode is not verified.
|
||||
@@ -1242,234 +1203,90 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
if mix_times is not None:
|
||||
mix_times = int(mix_times)
|
||||
|
||||
# 设置tip racks
|
||||
self.set_tiprack(tip_racks)
|
||||
|
||||
# 识别传输模式(mix_times 为 None 也应该能正常移液,只是不做 mix)
|
||||
num_sources = len(sources)
|
||||
num_targets = len(targets)
|
||||
len_asp_vols = len(asp_vols)
|
||||
len_dis_vols = len(dis_vols)
|
||||
|
||||
# if num_targets != 1 and num_sources != 1:
|
||||
# if len_asp_vols != num_sources and len_asp_vols != num_targets:
|
||||
# raise ValueError(f"asp_vols length must be equal to sources or targets length, but got {len_asp_vols} and {num_sources} and {num_targets}")
|
||||
# if len_dis_vols != num_sources and len_dis_vols != num_targets:
|
||||
# raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}")
|
||||
|
||||
if len(use_channels) != 8:
|
||||
max_len = max(num_sources, num_targets)
|
||||
for i in range(max_len):
|
||||
|
||||
# 辅助函数:安全地从列表中获取元素,如果列表为空则返回None
|
||||
def safe_get(lst, idx, default=None):
|
||||
return [lst[idx]] if lst else default
|
||||
|
||||
# 动态构建参数字典,只传递实际提供的参数
|
||||
kwargs = {
|
||||
'sources': [sources[i%num_sources]],
|
||||
'targets': [targets[i%num_targets]],
|
||||
'tip_racks': tip_racks,
|
||||
'use_channels': use_channels,
|
||||
'asp_vols': [asp_vols[i%len_asp_vols]],
|
||||
'dis_vols': [dis_vols[i%len_dis_vols]],
|
||||
}
|
||||
|
||||
# 条件性添加可选参数
|
||||
if asp_flow_rates is not None:
|
||||
kwargs['asp_flow_rates'] = [asp_flow_rates[i%len_asp_vols]]
|
||||
if dis_flow_rates is not None:
|
||||
kwargs['dis_flow_rates'] = [dis_flow_rates[i%len_dis_vols]]
|
||||
if offsets is not None:
|
||||
kwargs['offsets'] = safe_get(offsets, i)
|
||||
if touch_tip is not None:
|
||||
kwargs['touch_tip'] = touch_tip if touch_tip else False
|
||||
if liquid_height is not None:
|
||||
kwargs['liquid_height'] = safe_get(liquid_height, i)
|
||||
if blow_out_air_volume is not None:
|
||||
kwargs['blow_out_air_volume'] = safe_get(blow_out_air_volume, i)
|
||||
if spread is not None:
|
||||
kwargs['spread'] = spread
|
||||
if mix_stage is not None:
|
||||
kwargs['mix_stage'] = safe_get(mix_stage, i)
|
||||
if mix_times is not None:
|
||||
kwargs['mix_times'] = safe_get(mix_times, i)
|
||||
if mix_vol is not None:
|
||||
kwargs['mix_vol'] = safe_get(mix_vol, i)
|
||||
if mix_rate is not None:
|
||||
kwargs['mix_rate'] = safe_get(mix_rate, i)
|
||||
if mix_liquid_height is not None:
|
||||
kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i)
|
||||
if delays is not None:
|
||||
kwargs['delays'] = safe_get(delays, i)
|
||||
|
||||
await self._transfer_base_method(**kwargs)
|
||||
|
||||
|
||||
|
||||
# if num_sources == 1 and num_targets > 1:
|
||||
# # 模式1: 一对多 (1 source -> N targets)
|
||||
# await self._transfer_one_to_many(
|
||||
# sources,
|
||||
# targets,
|
||||
# tip_racks,
|
||||
# use_channels,
|
||||
# asp_vols,
|
||||
# dis_vols,
|
||||
# asp_flow_rates,
|
||||
# dis_flow_rates,
|
||||
# offsets,
|
||||
# touch_tip,
|
||||
# liquid_height,
|
||||
# blow_out_air_volume,
|
||||
# spread,
|
||||
# mix_stage,
|
||||
# mix_times,
|
||||
# mix_vol,
|
||||
# mix_rate,
|
||||
# mix_liquid_height,
|
||||
# delays,
|
||||
# )
|
||||
# elif num_sources > 1 and num_targets == 1:
|
||||
# # 模式2: 多对一 (N sources -> 1 target)
|
||||
# await self._transfer_many_to_one(
|
||||
# sources,
|
||||
# targets[0],
|
||||
# tip_racks,
|
||||
# use_channels,
|
||||
# asp_vols,
|
||||
# dis_vols,
|
||||
# asp_flow_rates,
|
||||
# dis_flow_rates,
|
||||
# offsets,
|
||||
# touch_tip,
|
||||
# liquid_height,
|
||||
# blow_out_air_volume,
|
||||
# spread,
|
||||
# mix_stage,
|
||||
# mix_times,
|
||||
# mix_vol,
|
||||
# mix_rate,
|
||||
# mix_liquid_height,
|
||||
# delays,
|
||||
# )
|
||||
# elif num_sources == num_targets:
|
||||
# # 模式3: 一对一 (N sources -> N targets)
|
||||
# await self._transfer_one_to_one(
|
||||
# sources,
|
||||
# targets,
|
||||
# tip_racks,
|
||||
# use_channels,
|
||||
# asp_vols,
|
||||
# dis_vols,
|
||||
# asp_flow_rates,
|
||||
# dis_flow_rates,
|
||||
# offsets,
|
||||
# touch_tip,
|
||||
# liquid_height,
|
||||
# blow_out_air_volume,
|
||||
# spread,
|
||||
# mix_stage,
|
||||
# mix_times,
|
||||
# mix_vol,
|
||||
# mix_rate,
|
||||
# mix_liquid_height,
|
||||
# delays,
|
||||
# )
|
||||
# else:
|
||||
# raise ValueError(
|
||||
# f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
|
||||
# "Supported modes: 1->N, N->1, or N->N."
|
||||
# )
|
||||
if num_sources == 1 and num_targets > 1:
|
||||
# 模式1: 一对多 (1 source -> N targets)
|
||||
await self._transfer_one_to_many(
|
||||
sources[0],
|
||||
targets,
|
||||
tip_racks,
|
||||
use_channels,
|
||||
asp_vols,
|
||||
dis_vols,
|
||||
asp_flow_rates,
|
||||
dis_flow_rates,
|
||||
offsets,
|
||||
touch_tip,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
mix_stage,
|
||||
mix_times,
|
||||
mix_vol,
|
||||
mix_rate,
|
||||
mix_liquid_height,
|
||||
delays,
|
||||
)
|
||||
elif num_sources > 1 and num_targets == 1:
|
||||
# 模式2: 多对一 (N sources -> 1 target)
|
||||
await self._transfer_many_to_one(
|
||||
sources,
|
||||
targets[0],
|
||||
tip_racks,
|
||||
use_channels,
|
||||
asp_vols,
|
||||
dis_vols,
|
||||
asp_flow_rates,
|
||||
dis_flow_rates,
|
||||
offsets,
|
||||
touch_tip,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
mix_stage,
|
||||
mix_times,
|
||||
mix_vol,
|
||||
mix_rate,
|
||||
mix_liquid_height,
|
||||
delays,
|
||||
)
|
||||
elif num_sources == num_targets:
|
||||
# 模式3: 一对一 (N sources -> N targets)
|
||||
await self._transfer_one_to_one(
|
||||
sources,
|
||||
targets,
|
||||
tip_racks,
|
||||
use_channels,
|
||||
asp_vols,
|
||||
dis_vols,
|
||||
asp_flow_rates,
|
||||
dis_flow_rates,
|
||||
offsets,
|
||||
touch_tip,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
mix_stage,
|
||||
mix_times,
|
||||
mix_vol,
|
||||
mix_rate,
|
||||
mix_liquid_height,
|
||||
delays,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
|
||||
"Supported modes: 1->N, N->1, or N->N."
|
||||
)
|
||||
|
||||
return TransferLiquidReturn(
|
||||
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
|
||||
targets=ResourceTreeSet.from_plr_resources(list(targets), known_newly_created=False).dump(), # type: ignore
|
||||
)
|
||||
async def _transfer_base_method(
|
||||
self,
|
||||
sources: Sequence[Container],
|
||||
targets: Sequence[Container],
|
||||
tip_racks: Sequence[TipRack],
|
||||
use_channels: List[int],
|
||||
asp_vols: List[float],
|
||||
dis_vols: List[float],
|
||||
**kwargs
|
||||
):
|
||||
|
||||
# 从kwargs中提取参数,提供默认值
|
||||
asp_flow_rates = kwargs.get('asp_flow_rates')
|
||||
dis_flow_rates = kwargs.get('dis_flow_rates')
|
||||
offsets = kwargs.get('offsets')
|
||||
touch_tip = kwargs.get('touch_tip', False)
|
||||
liquid_height = kwargs.get('liquid_height')
|
||||
blow_out_air_volume = kwargs.get('blow_out_air_volume')
|
||||
spread = kwargs.get('spread', 'wide')
|
||||
mix_stage = kwargs.get('mix_stage')
|
||||
mix_times = kwargs.get('mix_times')
|
||||
mix_vol = kwargs.get('mix_vol')
|
||||
mix_rate = kwargs.get('mix_rate')
|
||||
mix_liquid_height = kwargs.get('mix_liquid_height')
|
||||
delays = kwargs.get('delays')
|
||||
|
||||
tip = []
|
||||
tip.extend(self._get_next_tip())
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[targets[0]],
|
||||
mix_time=mix_times,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
resources=[sources[0]],
|
||||
vols=[asp_vols[0]],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None,
|
||||
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
|
||||
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
|
||||
blow_out_air_volume=(
|
||||
[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
|
||||
),
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
await self.dispense(
|
||||
resources=[targets[0]],
|
||||
vols=[dis_vols[0]],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None,
|
||||
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
|
||||
blow_out_air_volume=(
|
||||
[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
|
||||
),
|
||||
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[targets[0]],
|
||||
mix_time=mix_times,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
await self.touch_tip(targets[0])
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
async def _transfer_one_to_one(
|
||||
self,
|
||||
sources: Sequence[Container],
|
||||
@@ -1495,23 +1312,17 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
"""一对一传输模式:N sources -> N targets"""
|
||||
# 验证参数长度
|
||||
if len(asp_vols) != len(targets):
|
||||
if len(asp_vols) == 1:
|
||||
asp_vols = [asp_vols[0]] * len(targets)
|
||||
else:
|
||||
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
|
||||
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
|
||||
if len(dis_vols) != len(targets):
|
||||
if len(dis_vols) == 1:
|
||||
dis_vols = [dis_vols[0]] * len(targets)
|
||||
else:
|
||||
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
|
||||
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
|
||||
if len(sources) != len(targets):
|
||||
raise ValueError(f"Length of `sources` {len(sources)} must match `targets` {len(targets)}.")
|
||||
|
||||
if len(use_channels) != 1:
|
||||
if len(use_channels) == 1:
|
||||
for _ in range(len(targets)):
|
||||
tip = []
|
||||
for ___ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
@@ -1522,7 +1333,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
@@ -1561,7 +1371,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -1575,7 +1384,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
for i in range(0, len(targets), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
current_targets = targets[i : i + 8]
|
||||
current_reagent_sources = sources[i : i + 8]
|
||||
@@ -1598,7 +1407,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
@@ -1635,7 +1443,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -1680,7 +1487,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
# 单通道模式:一次吸液,多次分液
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
@@ -1692,7 +1499,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[idx : idx + 1] if offsets and len(offsets) > idx else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
# 从源容器吸液(总体积)
|
||||
@@ -1736,7 +1542,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[idx : idx + 1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
@@ -1752,7 +1557,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
for i in range(0, len(targets), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
current_targets = targets[i : i + 8]
|
||||
@@ -1780,7 +1585,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[i : i + 8] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
# 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同)
|
||||
@@ -1826,7 +1630,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
@@ -1859,11 +1662,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
"""多对一传输模式:N sources -> 1 target(汇总/混合)"""
|
||||
# 验证和扩展体积参数
|
||||
if len(asp_vols) != len(sources):
|
||||
if len(asp_vols) == 1:
|
||||
asp_vols = [asp_vols[0]] * len(sources)
|
||||
else:
|
||||
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.")
|
||||
|
||||
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.")
|
||||
|
||||
# 支持两种模式:
|
||||
# 1. dis_vols 为单个值:所有源汇总,使用总吸液体积或指定分液体积
|
||||
# 2. dis_vols 长度等于 asp_vols:每个源按不同比例分液(按比例混合)
|
||||
@@ -1881,19 +1681,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
f"(matching `asp_vols`). Got length {len(dis_vols)}."
|
||||
)
|
||||
|
||||
need_mix_after = mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0
|
||||
defer_final_discard = need_mix_after or touch_tip
|
||||
|
||||
if len(use_channels) == 1:
|
||||
# 单通道模式:多次吸液,一次分液
|
||||
|
||||
# 如果需要 before mix,先 pick up tip 并执行 mix
|
||||
# 先混合前(如果需要)
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1901,16 +1692,13 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
# 从每个源容器吸液并分液到目标容器
|
||||
for idx, source in enumerate(sources):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.aspirate(
|
||||
@@ -1963,11 +1751,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
if not (defer_final_discard and idx == len(sources) - 1):
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
# 最后在目标容器中混合(如果需要)
|
||||
if need_mix_after:
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1975,15 +1762,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
if defer_final_discard:
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
elif len(use_channels) == 8:
|
||||
# 8通道模式:需要确保源数量是8的倍数
|
||||
if len(sources) % 8 != 0:
|
||||
@@ -1991,11 +1774,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
|
||||
# 每次处理8个源
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -2003,15 +1781,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
for i in range(0, len(sources), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(self._get_next_tip())
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
current_sources = sources[i : i + 8]
|
||||
@@ -2070,11 +1845,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
if not (defer_final_discard and i + 8 >= len(sources)):
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
await self.discard_tips([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
|
||||
# 最后在目标容器中混合(如果需要)
|
||||
if need_mix_after:
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -2082,15 +1856,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
if defer_final_discard:
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
# except Exception as e:
|
||||
# traceback.print_exc()
|
||||
# raise RuntimeError(f"Liquid addition failed: {e}") from e
|
||||
@@ -2109,12 +1879,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
print(f"Waiting time: {msg}")
|
||||
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||
print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}")
|
||||
# Use ROS node sleep if available, otherwise use asyncio.sleep
|
||||
if hasattr(self, '_ros_node') and self._ros_node is not None:
|
||||
await self._ros_node.sleep(seconds)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.sleep(seconds)
|
||||
await self._ros_node.sleep(seconds)
|
||||
if msg:
|
||||
print(f"Done: {msg}")
|
||||
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||
@@ -2152,73 +1917,34 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
height_to_bottom: Optional[float] = None,
|
||||
offsets: Optional[Coordinate] = None,
|
||||
mix_rate: Optional[float] = None,
|
||||
use_channels: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
if mix_time is None or mix_time <= 0: # No mixing required
|
||||
if mix_time is None: # No mixing required
|
||||
return
|
||||
"""Mix the liquid in the target wells."""
|
||||
if mix_vol is None:
|
||||
raise ValueError("`mix_vol` must be provided when `mix_time` is set.")
|
||||
|
||||
targets_list: List[Container] = list(targets)
|
||||
if len(targets_list) == 0:
|
||||
return
|
||||
|
||||
def _expand(value, count: int):
|
||||
if value is None:
|
||||
return [None] * count
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) != count:
|
||||
raise ValueError("Length of per-target parameters must match targets.")
|
||||
return list(value)
|
||||
return [value] * count
|
||||
|
||||
offsets_list = _expand(offsets, len(targets_list))
|
||||
heights_list = _expand(height_to_bottom, len(targets_list))
|
||||
rates_list = _expand(mix_rate, len(targets_list))
|
||||
|
||||
for _ in range(mix_time):
|
||||
for idx, target in enumerate(targets_list):
|
||||
offset_arg = (
|
||||
[offsets_list[idx]] if offsets_list[idx] is not None else None
|
||||
)
|
||||
height_arg = (
|
||||
[heights_list[idx]] if heights_list[idx] is not None else None
|
||||
)
|
||||
rate_arg = [rates_list[idx]] if rates_list[idx] is not None else None
|
||||
|
||||
await self.aspirate(
|
||||
resources=[target],
|
||||
vols=[mix_vol],
|
||||
use_channels=use_channels,
|
||||
flow_rates=rate_arg,
|
||||
offsets=offset_arg,
|
||||
liquid_height=height_arg,
|
||||
)
|
||||
await self.custom_delay(seconds=1)
|
||||
await self.dispense(
|
||||
resources=[target],
|
||||
vols=[mix_vol],
|
||||
use_channels=use_channels,
|
||||
flow_rates=rate_arg,
|
||||
offsets=offset_arg,
|
||||
liquid_height=height_arg,
|
||||
)
|
||||
await self.aspirate(
|
||||
resources=[targets],
|
||||
vols=[mix_vol],
|
||||
flow_rates=[mix_rate] if mix_rate else None,
|
||||
offsets=[offsets] if offsets else None,
|
||||
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||
)
|
||||
await self.custom_delay(seconds=1)
|
||||
await self.dispense(
|
||||
resources=[targets],
|
||||
vols=[mix_vol],
|
||||
flow_rates=[mix_rate] if mix_rate else None,
|
||||
offsets=[offsets] if offsets else None,
|
||||
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||
)
|
||||
|
||||
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
|
||||
"""Yield tips from a list of TipRacks one-by-one until depleted."""
|
||||
for rack in tip_racks:
|
||||
for tip in rack:
|
||||
yield tip
|
||||
# raise RuntimeError("Out of tips!")
|
||||
|
||||
def _get_next_tip(self):
|
||||
"""从 current_tip 迭代器获取下一个 tip,耗尽时抛出明确错误而非 StopIteration"""
|
||||
try:
|
||||
return next(self.current_tip)
|
||||
except StopIteration as e:
|
||||
raise RuntimeError("Tip rack exhausted: no more tips available for transfer") from e
|
||||
raise RuntimeError("Out of tips!")
|
||||
|
||||
def set_tiprack(self, tip_racks: Sequence[TipRack]):
|
||||
"""Set the tip racks for the liquid handler."""
|
||||
|
||||
@@ -45,7 +45,6 @@ from pylabrobot.resources import (
|
||||
Trash,
|
||||
PlateAdapter,
|
||||
TubeRack,
|
||||
create_homogeneous_resources,
|
||||
)
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||
@@ -56,8 +55,7 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||
TransferLiquidReturn,
|
||||
)
|
||||
from unilabos.registry.placeholder_type import ResourceSlot
|
||||
from unilabos.resources.itemized_carrier import ItemizedCarrier
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
class PRCXIError(RuntimeError):
|
||||
@@ -93,12 +91,9 @@ class PRCXI9300Deck(Deck):
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||
super().__init__(name, size_x, size_y, size_z)
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位
|
||||
self.slot_locations = []
|
||||
|
||||
for i in range(0, 16):
|
||||
self.slot_locations.append(Coordinate((i%4)*137.5+5, (3-int(i/4))*96+13, 0))
|
||||
self.slot_locations = [Coordinate(0, 0, 0)] * 16
|
||||
|
||||
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
|
||||
if self.slots[slot - 1] is not None and not reassign:
|
||||
@@ -180,30 +175,9 @@ class PRCXI9300Plate(Plate):
|
||||
elif value is None:
|
||||
ordering_param = ordering
|
||||
else:
|
||||
# ordering 的值是对象(可能是 Well 对象),检查是否有有效的 location
|
||||
# 如果是反序列化过程,Well 对象可能没有正确的 location,需要让 Plate 重新创建
|
||||
sample_value = next(iter(ordering.values()), None)
|
||||
if sample_value is not None and hasattr(sample_value, 'location'):
|
||||
# 如果是 Well 对象但 location 为 None,说明是反序列化过程
|
||||
# 让 Plate 自己创建 Well 对象
|
||||
if sample_value.location is None:
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# Well 对象有有效的 location,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
elif sample_value is None:
|
||||
# ordering 的值都是 None,让 Plate 自己创建 Well 对象
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# 其他情况,直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict() # 提供空的 ordering
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items is not None:
|
||||
@@ -274,26 +248,20 @@ class PRCXI9300TipRack(TipRack):
|
||||
if ordered_items is not None:
|
||||
items = ordered_items
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 TipRack 自己创建 Tip 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 检查 ordering 中的值类型来决定如何处理:
|
||||
# - 字符串值(从 JSON 反序列化): 只用键创建 ordering_param
|
||||
# - None 值(从第二次往返序列化): 同样只用键创建 ordering_param
|
||||
# - 对象值(已经是实际的 Resource 对象): 直接作为 ordered_items 使用
|
||||
first_val = next(iter(ordering.values()), None) if ordering else None
|
||||
if not ordering or first_val is None or isinstance(first_val, str):
|
||||
# ordering 的值是字符串或 None,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TipRack 自己创建 Tip 对象
|
||||
items = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,需要过滤掉 None 值
|
||||
# 只保留有效的对象,用于 ordered_items 参数
|
||||
valid_items = {k: v for k, v in ordering.items() if v is not None}
|
||||
if valid_items:
|
||||
items = valid_items
|
||||
ordering_param = None
|
||||
else:
|
||||
# 如果没有有效对象,使用 ordering 参数
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
@@ -352,11 +320,17 @@ class PRCXI9300Trash(Trash):
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
category: str = "plate",
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "trash",
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
if name != "trash":
|
||||
print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.")
|
||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
||||
@@ -424,25 +398,20 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
items_to_pass = ordered_items
|
||||
ordering_param = None
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,这种情况下我们让 TubeRack 使用默认行为
|
||||
# 不在初始化时创建 items,而是在 deserialize 后处理
|
||||
# 检查 ordering 中的值类型来决定如何处理:
|
||||
# - 字符串值(从 JSON 反序列化): 只用键创建 ordering_param
|
||||
# - None 值(从第二次往返序列化): 同样只用键创建 ordering_param
|
||||
# - 对象值(已经是实际的 Resource 对象): 直接作为 ordered_items 使用
|
||||
first_val = next(iter(ordering.values()), None) if ordering else None
|
||||
if not ordering or first_val is None or isinstance(first_val, str):
|
||||
# ordering 的值是字符串或 None,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TubeRack 自己创建 Tube 对象
|
||||
items_to_pass = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) # 提供空的 ordering 来满足要求
|
||||
# 保存 ordering 信息以便后续处理
|
||||
self._temp_ordering = ordering
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,需要过滤掉 None 值
|
||||
# 只保留有效的对象,用于 ordered_items 参数
|
||||
valid_items = {k: v for k, v in ordering.items() if v is not None}
|
||||
if valid_items:
|
||||
items_to_pass = valid_items
|
||||
ordering_param = None
|
||||
else:
|
||||
# 如果没有有效对象,创建空的 ordered_items
|
||||
items_to_pass = {}
|
||||
ordering_param = None
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items_to_pass = ordering
|
||||
ordering_param = None
|
||||
elif items is not None:
|
||||
# 兼容旧的 items 参数
|
||||
items_to_pass = items
|
||||
@@ -464,29 +433,6 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
# 如果有临时 ordering 信息,在初始化完成后处理
|
||||
if hasattr(self, '_temp_ordering') and self._temp_ordering:
|
||||
self._process_temp_ordering()
|
||||
|
||||
def _process_temp_ordering(self):
|
||||
"""处理临时的 ordering 信息,创建相应的 Tube 对象"""
|
||||
from pylabrobot.resources import Tube, Coordinate
|
||||
|
||||
for location, item_type in self._temp_ordering.items():
|
||||
if item_type == 'Tube' or item_type == 'tube':
|
||||
# 为每个位置创建 Tube 对象
|
||||
tube = Tube(name=f"{self.name}_{location}", size_x=10, size_y=10, size_z=50, max_volume=2000.0)
|
||||
# 使用 assign_child_resource 添加到 rack 中
|
||||
self.assign_child_resource(tube, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 清理临时数据
|
||||
del self._temp_ordering
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""从给定的状态加载工作台信息。"""
|
||||
# super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
@@ -513,97 +459,6 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
|
||||
data.update(safe_state)
|
||||
return data
|
||||
class PRCXI9300PlateAdapterSite(ItemizedCarrier):
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
material_info: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
# 处理 sites 参数的不同格式
|
||||
|
||||
sites = create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(0, 0, 0)],
|
||||
resource_size_x=size_x,
|
||||
resource_size_y=size_y,
|
||||
resource_size_z=size_z,
|
||||
name_prefix=name,
|
||||
)[0]
|
||||
|
||||
# 确保不传递重复的参数
|
||||
kwargs.pop('layout', None)
|
||||
sites_in = kwargs.pop('sites', None)
|
||||
|
||||
# 创建默认的sites字典
|
||||
sites_dict = {name: sites}
|
||||
# 优先从 sites_in 读取 'content_type',否则使用默认值
|
||||
|
||||
content_type = [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
# 如果提供了sites参数,则用sites_in中的值替换sites_dict中对应的元素
|
||||
if sites_in is not None and isinstance(sites_in, dict):
|
||||
for site_key, site_value in sites_in.items():
|
||||
if site_key in sites_dict:
|
||||
sites_dict[site_key] = site_value
|
||||
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
sites=sites_dict,
|
||||
num_items_x=kwargs.pop('num_items_x', 1),
|
||||
num_items_y=kwargs.pop('num_items_y', 1),
|
||||
num_items_z=kwargs.pop('num_items_z', 1),
|
||||
content_type=content_type,
|
||||
**kwargs)
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
|
||||
def assign_child_resource(self, resource, location=Coordinate(0, 0, 0), reassign=True, spot=None):
|
||||
"""重写 assign_child_resource 方法,对于适配器位置,不使用索引分配"""
|
||||
# 直接调用 Resource 的 assign_child_resource,避免 ItemizedCarrier 的索引逻辑
|
||||
from pylabrobot.resources.resource import Resource
|
||||
Resource.assign_child_resource(self, resource, location=location, reassign=reassign)
|
||||
|
||||
def unassign_child_resource(self, resource):
|
||||
"""重写 unassign_child_resource 方法,对于适配器位置,不使用 sites 列表"""
|
||||
# 直接调用 Resource 的 unassign_child_resource,避免 ItemizedCarrier 的 sites 逻辑
|
||||
from pylabrobot.resources.resource import Resource
|
||||
Resource.unassign_child_resource(self, resource)
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
except AttributeError:
|
||||
data = {}
|
||||
|
||||
# 包含 sites 配置信息,但避免序列化 ResourceHolder 对象
|
||||
if hasattr(self, 'sites') and self.sites:
|
||||
# 只保存 sites 的基本信息,不保存 ResourceHolder 对象本身
|
||||
sites_info = []
|
||||
for site in self.sites:
|
||||
if hasattr(site, '__class__') and 'pylabrobot' in str(site.__class__.__module__):
|
||||
# 对于 pylabrobot 对象,只保存基本信息
|
||||
sites_info.append({
|
||||
"__pylabrobot_object__": True,
|
||||
"class": site.__class__.__name__,
|
||||
"module": site.__class__.__module__,
|
||||
"name": getattr(site, 'name', str(site))
|
||||
})
|
||||
else:
|
||||
sites_info.append(site)
|
||||
data['sites'] = sites_info
|
||||
|
||||
return data
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""加载状态,包括 sites 配置信息"""
|
||||
super().load_state(state)
|
||||
|
||||
# 从状态中恢复 sites 配置信息
|
||||
if 'sites' in state:
|
||||
self.sites = [state['sites']]
|
||||
|
||||
|
||||
class PRCXI9300PlateAdapter(PlateAdapter):
|
||||
@@ -708,51 +563,18 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
step_mode=False,
|
||||
matrix_id="",
|
||||
is_9320=False,
|
||||
start_rail=2,
|
||||
rail_nums=4,
|
||||
rail_interval=0,
|
||||
x_increase = -0.003636,
|
||||
y_increase = -0.003636,
|
||||
x_offset = -0.8,
|
||||
y_offset = -37.98,
|
||||
deck_z = 300,
|
||||
deck_y = 400,
|
||||
rail_width=27.5,
|
||||
xy_coupling = -0.0045,
|
||||
):
|
||||
|
||||
self.deck_x = (start_rail + rail_nums*5 + (rail_nums-1)*rail_interval) * rail_width
|
||||
self.deck_y = deck_y
|
||||
self.deck_z = deck_z
|
||||
self.x_increase = x_increase
|
||||
self.y_increase = y_increase
|
||||
self.x_offset = x_offset
|
||||
self.y_offset = y_offset
|
||||
self.xy_coupling = xy_coupling
|
||||
|
||||
tablets_info = {}
|
||||
plate_positions = []
|
||||
tablets_info = []
|
||||
count = 0
|
||||
for child in deck.children:
|
||||
number = int(child.name.replace("T", ""))
|
||||
|
||||
if child.children:
|
||||
if "Material" in child.children[0]._unilabos_state:
|
||||
tablets_info[number] = child.children[0]._unilabos_state["Material"].get("uuid", "730067cf07ae43849ddf4034299030e9")
|
||||
else:
|
||||
tablets_info[number] = "730067cf07ae43849ddf4034299030e9"
|
||||
else:
|
||||
tablets_info[number] = "730067cf07ae43849ddf4034299030e9"
|
||||
pos = self.plr_pos_to_prcxi(child)
|
||||
plate_positions.append(
|
||||
{
|
||||
"Number": number,
|
||||
"XPos": pos.x,
|
||||
"YPos": pos.y,
|
||||
"ZPos": pos.z
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]
|
||||
)
|
||||
)
|
||||
if is_9320:
|
||||
print("当前设备是9320")
|
||||
# 始终初始化 step_mode 属性
|
||||
@@ -762,38 +584,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
self.step_mode = step_mode
|
||||
else:
|
||||
print("9300设备不支持 单点动作模式")
|
||||
|
||||
self._unilabos_backend = PRCXI9300Backend(
|
||||
tablets_info, plate_positions, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320,
|
||||
x_increase, y_increase, x_offset, y_offset,
|
||||
deck_z, deck_x=self.deck_x, deck_y=self.deck_y, xy_coupling=xy_coupling
|
||||
tablets_info, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320
|
||||
)
|
||||
|
||||
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
|
||||
|
||||
def plr_pos_to_prcxi(self, resource: Resource):
|
||||
resource_pos = resource.get_absolute_location(x="c",y="c",z="t")
|
||||
x = resource_pos.x
|
||||
y = resource_pos.y
|
||||
z = resource_pos.z
|
||||
# 如果z等于0,则递归resource.parent的高度并向z加,使用get_size_z方法
|
||||
|
||||
parent = resource.parent
|
||||
res_z = resource.location.z
|
||||
while not isinstance(parent, LiquidHandlerAbstract) and (res_z == 0) and parent is not None:
|
||||
z += parent.get_size_z()
|
||||
res_z = parent.location.z
|
||||
parent = getattr(parent, "parent", None)
|
||||
|
||||
prcxi_x = (self.deck_x - x)*(1+self.x_increase) + self.x_offset + self.xy_coupling * (self.deck_y - y)
|
||||
prcxi_y = (self.deck_y - y)*(1+self.y_increase) + self.y_offset
|
||||
prcxi_z = self.deck_z - z
|
||||
|
||||
prcxi_x = min(max(0, prcxi_x),self.deck_x)
|
||||
prcxi_y = min(max(0, prcxi_y),self.deck_y)
|
||||
prcxi_z = min(max(0, prcxi_z),self.deck_z)
|
||||
|
||||
return Coordinate(prcxi_x, prcxi_y, prcxi_z)
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
super().post_init(ros_node)
|
||||
@@ -927,36 +721,29 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
delays: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
) -> TransferLiquidReturn:
|
||||
if self.step_mode:
|
||||
await self.create_protocol(f"step_mode_protocol_{time.time()}")
|
||||
|
||||
res =await super().transfer_liquid(
|
||||
sources,
|
||||
targets,
|
||||
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,
|
||||
)
|
||||
if self.step_mode:
|
||||
await self.run_protocol()
|
||||
|
||||
return res
|
||||
return await super().transfer_liquid(
|
||||
sources,
|
||||
targets,
|
||||
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,
|
||||
)
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
return await super().custom_delay(seconds, msg)
|
||||
@@ -1006,32 +793,18 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
try:
|
||||
return await super().aspirate(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
except ValueError as e:
|
||||
if "Resource is too small to space channels" in str(e) and spread != "custom":
|
||||
return await super().aspirate(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread="custom",
|
||||
**backend_kwargs,
|
||||
)
|
||||
raise
|
||||
|
||||
return await super().aspirate(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
async def drop_tips(
|
||||
self,
|
||||
@@ -1055,33 +828,17 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
try:
|
||||
return await super().dispense(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
except ValueError as e:
|
||||
if "Resource is too small to space channels" in str(e) and spread != "custom":
|
||||
# 目标资源过小无法分布多通道时,退化为 custom(所有通道对准中心)
|
||||
return await super().dispense(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
"custom",
|
||||
**backend_kwargs,
|
||||
)
|
||||
raise
|
||||
return await super().dispense(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
flow_rates,
|
||||
offsets,
|
||||
liquid_height,
|
||||
blow_out_air_volume,
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
async def discard_tips(
|
||||
self,
|
||||
@@ -1101,11 +858,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
return await self._unilabos_backend.shaker_action(time, module_no, amplitude, is_wait)
|
||||
|
||||
async def magnetic_action(self, time: int, module_no: int, height: int, is_wait: bool):
|
||||
return await self._unilabos_backend.magnetic_action(time, module_no, height, is_wait)
|
||||
|
||||
async def shaking_incubation_action(self, time: int, module_no: int, amplitude: int, is_wait: bool, temperature: int):
|
||||
return await self._unilabos_backend.shaking_incubation_action(time, module_no, amplitude, is_wait, temperature)
|
||||
async def heater_action(self, temperature: float, time: int):
|
||||
return await self._unilabos_backend.heater_action(temperature, time)
|
||||
|
||||
@@ -1122,7 +874,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
res = await super().move_plate(
|
||||
return await super().move_plate(
|
||||
plate,
|
||||
to,
|
||||
intermediate_locations,
|
||||
@@ -1134,12 +886,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
target_plate_number=to,
|
||||
**backend_kwargs,
|
||||
)
|
||||
plate.unassign()
|
||||
to.assign_child_resource(plate, location=Coordinate(0, 0, 0))
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
})
|
||||
return res
|
||||
|
||||
|
||||
class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
@@ -1164,7 +910,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
def __init__(
|
||||
self,
|
||||
tablets_info: list[WorkTablets],
|
||||
plate_positions: dict[int, Coordinate],
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 9999,
|
||||
timeout: float = 10.0,
|
||||
@@ -1173,19 +918,10 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
setup=True,
|
||||
debug=False,
|
||||
matrix_id="",
|
||||
is_9320=False,
|
||||
x_increase = 0,
|
||||
y_increase = 0,
|
||||
x_offset = 0,
|
||||
y_offset = 0,
|
||||
deck_z = 300,
|
||||
deck_x = 0,
|
||||
deck_y = 0,
|
||||
xy_coupling = 0.0,
|
||||
is_9320=False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.tablets_info = tablets_info
|
||||
self.plate_positions = plate_positions
|
||||
self.matrix_id = matrix_id
|
||||
self.api_client = PRCXI9300Api(host, port, timeout, axis, debug, is_9320)
|
||||
self.host, self.port, self.timeout = host, port, timeout
|
||||
@@ -1193,15 +929,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
self._execute_setup = setup
|
||||
self.debug = debug
|
||||
self.axis = "Left"
|
||||
self.x_increase = x_increase
|
||||
self.y_increase = y_increase
|
||||
self.xy_coupling = xy_coupling
|
||||
self.x_offset = x_offset
|
||||
self.y_offset = y_offset
|
||||
self.deck_x = deck_x
|
||||
self.deck_y = deck_y
|
||||
self.deck_z = deck_z
|
||||
self.tip_length = 0
|
||||
|
||||
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
step = self.api_client.shaker_action(
|
||||
@@ -1213,27 +940,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
async def shaking_incubation_action(self, time: int, module_no: int, amplitude: int, is_wait: bool, temperature: int):
|
||||
step = self.api_client.shaking_incubation_action(
|
||||
time=time,
|
||||
module_no=module_no,
|
||||
amplitude=amplitude,
|
||||
is_wait=is_wait,
|
||||
temperature=temperature,
|
||||
)
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
async def magnetic_action(self, time: int, module_no: int, height: int, is_wait: bool):
|
||||
step = self.api_client.magnetic_action(
|
||||
time=time,
|
||||
module_no=module_no,
|
||||
height=height,
|
||||
is_wait=is_wait,
|
||||
)
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup, **backend_kwargs):
|
||||
|
||||
resource = pickup.resource
|
||||
@@ -1272,42 +978,29 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
self._ros_node = ros_node
|
||||
|
||||
def create_protocol(self, protocol_name):
|
||||
if protocol_name == "":
|
||||
protocol_name = f"protocol_{time.time()}"
|
||||
self.protocol_name = protocol_name
|
||||
self.steps_todo_list = []
|
||||
|
||||
if not len(self.matrix_id):
|
||||
self.matrix_id = str(uuid.uuid4())
|
||||
|
||||
material_list = self.api_client.get_all_materials()
|
||||
material_dict = {material["uuid"]: material for material in material_list}
|
||||
|
||||
work_tablets = []
|
||||
for num, material_id in self.tablets_info.items():
|
||||
work_tablets.append({
|
||||
"Number": num,
|
||||
"Material": material_dict[material_id]
|
||||
})
|
||||
|
||||
self.matrix_info = {
|
||||
"MatrixId": self.matrix_id,
|
||||
"MatrixName": self.matrix_id,
|
||||
"WorkTablets": work_tablets,
|
||||
}
|
||||
# print(json.dumps(self.matrix_info, indent=2))
|
||||
res = self.api_client.add_WorkTablet_Matrix(self.matrix_info)
|
||||
if not res["Success"]:
|
||||
self.matrix_id = ""
|
||||
raise AssertionError(f"Failed to create matrix: {res.get('Message', 'Unknown error')}")
|
||||
print(f"PRCXI9300Backend created matrix with ID: {self.matrix_info['MatrixId']}, result: {res}")
|
||||
|
||||
def run_protocol(self):
|
||||
assert self.is_reset_ok, "PRCXI9300Backend is not reset successfully. Please call setup() first."
|
||||
run_time = time.time()
|
||||
solution_id = self.api_client.add_solution(
|
||||
f"protocol_{run_time}", self.matrix_id, self.steps_todo_list
|
||||
self.matrix_info = MatrixInfo(
|
||||
MatrixId=f"{int(run_time)}",
|
||||
MatrixName=f"protocol_{run_time}",
|
||||
MatrixCount=len(self.tablets_info),
|
||||
WorkTablets=self.tablets_info,
|
||||
)
|
||||
# print(json.dumps(self.matrix_info, indent=2))
|
||||
if not len(self.matrix_id):
|
||||
res = self.api_client.add_WorkTablet_Matrix(self.matrix_info)
|
||||
assert res["Success"], f"Failed to create matrix: {res.get('Message', 'Unknown error')}"
|
||||
print(f"PRCXI9300Backend created matrix with ID: {self.matrix_info['MatrixId']}, result: {res}")
|
||||
solution_id = self.api_client.add_solution(
|
||||
f"protocol_{run_time}", self.matrix_info["MatrixId"], self.steps_todo_list
|
||||
)
|
||||
else:
|
||||
print(f"PRCXI9300Backend using predefined worktable {self.matrix_id}, skipping matrix creation.")
|
||||
solution_id = self.api_client.add_solution(f"protocol_{run_time}", self.matrix_id, self.steps_todo_list)
|
||||
print(f"PRCXI9300Backend created solution with ID: {solution_id}")
|
||||
self.api_client.load_solution(solution_id)
|
||||
print(json.dumps(self.steps_todo_list, indent=2))
|
||||
@@ -1350,9 +1043,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
print("PRCXI9300 reset successfully.")
|
||||
|
||||
self.api_client.update_clamp_jaw_position(self.matrix_id, self.plate_positions)
|
||||
|
||||
except ConnectionRefusedError as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to connect to PRCXI9300 API at {self.host}:{self.port}. "
|
||||
@@ -1401,7 +1091,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
PlateNo = plate_indexes[0] + 1
|
||||
hole_col = tip_columns[0] + 1
|
||||
hole_row = 1
|
||||
if self.num_channels != 8:
|
||||
if self._num_channels == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Load(
|
||||
@@ -1414,7 +1104,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
blending_times=0,
|
||||
balance_height=0,
|
||||
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
|
||||
hole_numbers=f"{(hole_col - 1) * 8 + hole_row}" if self._num_channels != 8 else "1,2,3,4,5",
|
||||
hole_numbers=f"{(hole_col - 1) * 8 + hole_row}" if self._num_channels == 1 else "1,2,3,4,5",
|
||||
)
|
||||
self.steps_todo_list.append(step)
|
||||
|
||||
@@ -1475,7 +1165,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
PlateNo = plate_indexes[0] + 1
|
||||
hole_col = tip_columns[0] + 1
|
||||
|
||||
if self.num_channels != 8:
|
||||
if self.channel_num == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.UnLoad(
|
||||
@@ -1527,7 +1217,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
PlateNo = plate_indexes[0] + 1
|
||||
hole_col = tip_columns[0] + 1
|
||||
hole_row = 1
|
||||
if self.num_channels != 8:
|
||||
if self.num_channels == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
assert mix_time > 0
|
||||
@@ -1584,7 +1274,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
PlateNo = plate_indexes[0] + 1
|
||||
hole_col = tip_columns[0] + 1
|
||||
hole_row = 1
|
||||
if self.num_channels != 8:
|
||||
if self.num_channels == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Imbibing(
|
||||
@@ -1642,7 +1332,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_col = tip_columns[0] + 1
|
||||
|
||||
hole_row = 1
|
||||
if self.num_channels != 8:
|
||||
if self.num_channels == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Tapping(
|
||||
@@ -1768,10 +1458,10 @@ class PRCXI9300Api:
|
||||
start = False
|
||||
while not success:
|
||||
status = self.step_state_list()
|
||||
if status is None:
|
||||
break
|
||||
if len(status) == 1:
|
||||
start = True
|
||||
if status is None:
|
||||
break
|
||||
if len(status) == 0:
|
||||
break
|
||||
if status[-1]["State"] == 2 and start:
|
||||
@@ -1850,13 +1540,6 @@ class PRCXI9300Api:
|
||||
"""GetWorkTabletMatrixById"""
|
||||
return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id])
|
||||
|
||||
def update_clamp_jaw_position(self, target_matrix_id: str, plate_positions: List[Dict[str, Any]]):
|
||||
position_params = {
|
||||
"MatrixId": target_matrix_id,
|
||||
"WorkTablets": plate_positions
|
||||
}
|
||||
return self.call("IMatrix", "UpdateClampJawPosition", [position_params])
|
||||
|
||||
def add_WorkTablet_Matrix(self, matrix: MatrixInfo):
|
||||
return self.call("IMatrix", "AddWorkTabletMatrix2" if self.is_9320 else "AddWorkTabletMatrix", [matrix])
|
||||
|
||||
@@ -2099,26 +1782,6 @@ class PRCXI9300Api:
|
||||
"AssistFun4": is_wait,
|
||||
}
|
||||
|
||||
def shaking_incubation_action(self, time: int, module_no: int, amplitude: int, is_wait: bool, temperature: int):
|
||||
return {
|
||||
"StepAxis": "Left",
|
||||
"Function": "Shaking_Incubation",
|
||||
"AssistFun1": time,
|
||||
"AssistFun2": module_no,
|
||||
"AssistFun3": amplitude,
|
||||
"AssistFun4": is_wait,
|
||||
"AssistFun5": temperature,
|
||||
}
|
||||
|
||||
def magnetic_action(self, time: int, module_no: int, height: int, is_wait: bool):
|
||||
return {
|
||||
"StepAxis": "Left",
|
||||
"Function": "Magnetic",
|
||||
"AssistFun1": time,
|
||||
"AssistFun2": module_no,
|
||||
"AssistFun3": height,
|
||||
"AssistFun4": is_wait,
|
||||
}
|
||||
|
||||
class DefaultLayout:
|
||||
|
||||
@@ -2150,82 +1813,82 @@ class DefaultLayout:
|
||||
{
|
||||
"Number": 1,
|
||||
"Code": "T1",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 2,
|
||||
"Code": "T2",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 3,
|
||||
"Code": "T3",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 4,
|
||||
"Code": "T4",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 5,
|
||||
"Code": "T5",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 6,
|
||||
"Code": "T6",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 7,
|
||||
"Code": "T7",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 8,
|
||||
"Code": "T8",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 9,
|
||||
"Code": "T9",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 10,
|
||||
"Code": "T10",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 11,
|
||||
"Code": "T11",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 12,
|
||||
"Code": "T12",
|
||||
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9"},
|
||||
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0},
|
||||
}, # 这个设置成废液槽,用储液槽表示
|
||||
{
|
||||
"Number": 13,
|
||||
"Code": "T13",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 14,
|
||||
"Code": "T14",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 15,
|
||||
"Code": "T15",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 16,
|
||||
"Code": "T16",
|
||||
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9"},
|
||||
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0},
|
||||
}, # 这个设置成垃圾桶,用储液槽表示
|
||||
],
|
||||
}
|
||||
|
||||
376
unilabos/devices/motor/ZDT_X42.py
Normal file
376
unilabos/devices/motor/ZDT_X42.py
Normal file
@@ -0,0 +1,376 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ZDT X42 Closed-Loop Stepper Motor Driver
|
||||
RS485 Serial Communication via USB-Serial Converter
|
||||
|
||||
- Baudrate: 115200
|
||||
"""
|
||||
|
||||
import serial
|
||||
import time
|
||||
import threading
|
||||
import struct
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
try:
|
||||
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||
except ImportError:
|
||||
class UniversalDriver:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
def execute_command_from_outer(self, command: Any): pass
|
||||
|
||||
from serial.rs485 import RS485Settings
|
||||
|
||||
|
||||
class ZDTX42Driver(UniversalDriver):
|
||||
"""
|
||||
ZDT X42 闭环步进电机驱动器
|
||||
|
||||
支持功能:
|
||||
- 速度模式运行
|
||||
- 位置模式运行 (相对/绝对)
|
||||
- 位置读取和清零
|
||||
- 使能/禁用控制
|
||||
|
||||
通信协议:
|
||||
- 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B]
|
||||
- 响应长度根据功能码决定
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
baudrate: int = 115200,
|
||||
device_id: int = 1,
|
||||
timeout: float = 0.5,
|
||||
debug: bool = False
|
||||
):
|
||||
"""
|
||||
初始化 ZDT X42 电机驱动
|
||||
|
||||
Args:
|
||||
port: 串口设备路径
|
||||
baudrate: 波特率 (默认 115200)
|
||||
device_id: 设备地址 (1-255)
|
||||
timeout: 通信超时时间(秒)
|
||||
debug: 是否启用调试输出
|
||||
"""
|
||||
super().__init__()
|
||||
self.id = device_id
|
||||
self.debug = debug
|
||||
self.lock = threading.RLock()
|
||||
self.status = "idle" # 对应注册表中的 status (str)
|
||||
self.position = 0 # 对应注册表中的 position (int)
|
||||
|
||||
try:
|
||||
self.ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
timeout=timeout,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE
|
||||
)
|
||||
|
||||
# 启用 RS485 模式
|
||||
try:
|
||||
self.ser.rs485_mode = RS485Settings(
|
||||
rts_level_for_tx=True,
|
||||
rts_level_for_rx=False
|
||||
)
|
||||
except Exception:
|
||||
pass # RS485 模式是可选的
|
||||
|
||||
self.logger.info(
|
||||
f"ZDT X42 Motor connected: {port} "
|
||||
f"(Baud: {baudrate}, ID: {device_id})"
|
||||
)
|
||||
# 自动使能电机,确保初始状态可运动
|
||||
self.enable(True)
|
||||
|
||||
# 启动背景轮询线程,确保 position 实时刷新
|
||||
self._stop_event = threading.Event()
|
||||
self._polling_thread = threading.Thread(
|
||||
target=self._update_loop,
|
||||
name=f"ZDTPolling_{port}",
|
||||
daemon=True
|
||||
)
|
||||
self._polling_thread.start()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to open serial port {port}: {e}")
|
||||
self.ser = None
|
||||
|
||||
def _update_loop(self):
|
||||
"""背景循环读取电机位置"""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self.get_position()
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
self.logger.error(f"Polling error: {e}")
|
||||
time.sleep(1.0) # 每1秒刷新一次位置数据
|
||||
|
||||
def _send(self, func_code: int, payload: list) -> bytes:
|
||||
"""
|
||||
发送指令并接收响应
|
||||
|
||||
Args:
|
||||
func_code: 功能码
|
||||
payload: 数据负载 (list of bytes)
|
||||
|
||||
Returns:
|
||||
响应数据 (bytes)
|
||||
"""
|
||||
if not self.ser:
|
||||
self.logger.error("Serial port not available")
|
||||
return b""
|
||||
|
||||
with self.lock:
|
||||
# 清空输入缓冲区
|
||||
self.ser.reset_input_buffer()
|
||||
|
||||
# 构建消息: [ID] [功能码] [数据...] [校验位=0x6B]
|
||||
message = bytes([self.id, func_code] + payload + [0x6B])
|
||||
|
||||
# 发送
|
||||
self.ser.write(message)
|
||||
|
||||
# 根据功能码决定响应长度
|
||||
# 查询类指令返回 10 字节,控制类指令返回 4 字节
|
||||
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
|
||||
response = self.ser.read(read_len)
|
||||
|
||||
# 调试输出
|
||||
if self.debug:
|
||||
sent_hex = message.hex().upper()
|
||||
recv_hex = response.hex().upper() if response else 'TIMEOUT'
|
||||
print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}")
|
||||
|
||||
return response
|
||||
|
||||
def enable(self, on: bool = True) -> bool:
|
||||
"""
|
||||
使能/禁用电机
|
||||
|
||||
Args:
|
||||
on: True=使能(锁轴), False=禁用(松轴)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
state = 1 if on else 0
|
||||
resp = self._send(0xF3, [0xAB, state, 0])
|
||||
return len(resp) >= 4
|
||||
|
||||
def move_speed(
|
||||
self,
|
||||
speed_rpm: int,
|
||||
direction: str = "CW",
|
||||
acceleration: int = 10
|
||||
) -> bool:
|
||||
"""
|
||||
速度模式运行
|
||||
|
||||
Args:
|
||||
speed_rpm: 转速 (RPM)
|
||||
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
|
||||
acceleration: 加速度 (0-255)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
|
||||
speed_bytes = struct.pack('>H', int(speed_rpm))
|
||||
self.status = f"moving@{speed_rpm}rpm"
|
||||
resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0])
|
||||
return len(resp) >= 4
|
||||
|
||||
def move_position(
|
||||
self,
|
||||
pulses: int,
|
||||
speed_rpm: int,
|
||||
direction: str = "CW",
|
||||
acceleration: int = 10,
|
||||
absolute: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
位置模式运行
|
||||
|
||||
Args:
|
||||
pulses: 脉冲数
|
||||
speed_rpm: 转速 (RPM)
|
||||
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
|
||||
acceleration: 加速度 (0-255)
|
||||
absolute: True=绝对位置, False=相对位置
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
|
||||
speed_bytes = struct.pack('>H', int(speed_rpm))
|
||||
self.status = f"moving_to_{pulses}"
|
||||
pulse_bytes = struct.pack('>I', int(pulses))
|
||||
abs_flag = 1 if absolute else 0
|
||||
|
||||
payload = [
|
||||
dir_val,
|
||||
speed_bytes[0], speed_bytes[1],
|
||||
acceleration,
|
||||
pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3],
|
||||
abs_flag,
|
||||
0
|
||||
]
|
||||
|
||||
resp = self._send(0xFD, payload)
|
||||
return len(resp) >= 4
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""
|
||||
停止电机
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
self.status = "idle"
|
||||
resp = self._send(0xFE, [0x98, 0])
|
||||
return len(resp) >= 4
|
||||
|
||||
def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool:
|
||||
"""
|
||||
电机旋转 1/4 圈 (阻塞式)
|
||||
假设电机细分为 3200 脉冲/圈,1/4 圈 = 800 脉冲
|
||||
"""
|
||||
pulses = 800
|
||||
success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False)
|
||||
|
||||
if success:
|
||||
# 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60))
|
||||
# 1/4 rev / (RPM/60) = 15.0 / RPM
|
||||
estimated_time = 15.0 / max(1, speed_rpm)
|
||||
time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲
|
||||
self.status = "idle"
|
||||
|
||||
return success
|
||||
|
||||
def wait_time(self, duration_s: float) -> bool:
|
||||
"""
|
||||
等待指定时间 (秒)
|
||||
"""
|
||||
self.logger.info(f"Waiting for {duration_s} seconds...")
|
||||
time.sleep(duration_s)
|
||||
return True
|
||||
|
||||
def set_zero(self) -> bool:
|
||||
"""
|
||||
清零当前位置
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
resp = self._send(0x0A, [])
|
||||
return len(resp) >= 4
|
||||
|
||||
def get_position(self) -> Optional[int]:
|
||||
"""
|
||||
读取当前位置 (脉冲数)
|
||||
|
||||
Returns:
|
||||
当前位置脉冲数,失败返回 None
|
||||
"""
|
||||
resp = self._send(0x32, [])
|
||||
|
||||
if len(resp) >= 8:
|
||||
# 响应格式: [ID] [Func] [符号位] [数值4字节] [校验]
|
||||
sign = resp[2] # 0=正, 1=负
|
||||
value = struct.unpack('>I', resp[3:7])[0]
|
||||
self.position = -value if sign == 1 else value
|
||||
|
||||
if self.debug:
|
||||
print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}")
|
||||
|
||||
return self.position
|
||||
|
||||
self.logger.warning("Failed to read position")
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""关闭串口连接并停止线程"""
|
||||
if hasattr(self, '_stop_event'):
|
||||
self._stop_event.set()
|
||||
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.close()
|
||||
self.logger.info("Serial port closed")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 测试和调试代码
|
||||
# ============================================================
|
||||
|
||||
def test_motor():
|
||||
"""基础功能测试"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("="*60)
|
||||
print("ZDT X42 电机驱动测试")
|
||||
print("="*60)
|
||||
|
||||
driver = ZDTX42Driver(
|
||||
port="/dev/tty.usbserial-3110",
|
||||
baudrate=115200,
|
||||
device_id=2,
|
||||
debug=True
|
||||
)
|
||||
|
||||
if not driver.ser:
|
||||
print("❌ 串口打开失败")
|
||||
return
|
||||
|
||||
try:
|
||||
# 测试 1: 读取位置
|
||||
print("\n[1] 读取当前位置")
|
||||
pos = driver.get_position()
|
||||
print(f"✓ 当前位置: {pos} 脉冲")
|
||||
|
||||
# 测试 2: 使能
|
||||
print("\n[2] 使能电机")
|
||||
driver.enable(True)
|
||||
time.sleep(0.3)
|
||||
print("✓ 电机已锁定")
|
||||
|
||||
# 测试 3: 相对位置运动
|
||||
print("\n[3] 相对位置运动 (1000脉冲)")
|
||||
driver.move_position(pulses=1000, speed_rpm=60, direction="CW")
|
||||
time.sleep(2)
|
||||
pos = driver.get_position()
|
||||
print(f"✓ 新位置: {pos}")
|
||||
|
||||
# 测试 4: 速度运动
|
||||
print("\n[4] 速度模式 (30RPM, 3秒)")
|
||||
driver.move_speed(speed_rpm=30, direction="CW")
|
||||
time.sleep(3)
|
||||
driver.stop()
|
||||
pos = driver.get_position()
|
||||
print(f"✓ 停止后位置: {pos}")
|
||||
|
||||
# 测试 5: 禁用
|
||||
print("\n[5] 禁用电机")
|
||||
driver.enable(False)
|
||||
print("✓ 电机已松开")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("✅ 测试完成")
|
||||
print("="*60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
driver.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_motor()
|
||||
@@ -623,6 +623,119 @@ class ChinweDevice(UniversalDriver):
|
||||
time.sleep(duration)
|
||||
return True
|
||||
|
||||
def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700,
|
||||
max_cycles: int = 0, timeout: int = 300) -> bool:
|
||||
"""
|
||||
分液步骤 - 液位传感器与电机联动
|
||||
当液位传感器检测到"有液"时,电机顺时针旋转指定脉冲数
|
||||
当液位传感器检测到"无液"时,电机逆时针旋转指定脉冲数
|
||||
|
||||
:param motor_id: 电机ID (必须在初始化时配置的motor_ids中)
|
||||
:param speed: 电机转速 (RPM)
|
||||
:param pulses: 每次旋转的脉冲数 (默认700约为1/4圈,假设3200脉冲/圈)
|
||||
:param max_cycles: 最大执行循环次数 (0=无限制,默认0)
|
||||
:param timeout: 整体超时时间 (秒)
|
||||
:return: 成功返回True,超时或失败返回False
|
||||
"""
|
||||
motor_id = int(motor_id)
|
||||
speed = int(speed)
|
||||
pulses = int(pulses)
|
||||
max_cycles = int(max_cycles)
|
||||
timeout = int(timeout)
|
||||
|
||||
# 检查电机是否存在
|
||||
if motor_id not in self.motors:
|
||||
self.logger.error(f"Motor {motor_id} not found in configured motors: {list(self.motors.keys())}")
|
||||
return False
|
||||
|
||||
# 检查传感器是否可用
|
||||
if not self.sensor:
|
||||
self.logger.error("Sensor not initialized")
|
||||
return False
|
||||
|
||||
motor = self.motors[motor_id]
|
||||
|
||||
# 停止轮询线程,避免与 separation_step 同时读取传感器造成串口冲突
|
||||
self.logger.info("Stopping polling thread for separation_step...")
|
||||
self._stop_event.set()
|
||||
if self._poll_thread and self._poll_thread.is_alive():
|
||||
self._poll_thread.join(timeout=2.0)
|
||||
|
||||
# 使能电机
|
||||
self.logger.info(f"Enabling motor {motor_id}...")
|
||||
motor.enable(True)
|
||||
time.sleep(0.2)
|
||||
|
||||
self.logger.info(f"Starting separation step: motor_id={motor_id}, speed={speed} RPM, "
|
||||
f"pulses={pulses}, max_cycles={max_cycles}, timeout={timeout}s")
|
||||
|
||||
# 记录上一次的液位状态
|
||||
last_level = None
|
||||
cycle_count = 0
|
||||
start_time = time.time()
|
||||
error_count = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 检查超时
|
||||
if time.time() - start_time > timeout:
|
||||
self.logger.warning(f"Separation step timeout after {timeout} seconds")
|
||||
return False
|
||||
|
||||
# 检查循环次数限制
|
||||
if max_cycles > 0 and cycle_count >= max_cycles:
|
||||
self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}")
|
||||
return True
|
||||
|
||||
# 读取传感器数据
|
||||
data = self.sensor.read_level()
|
||||
|
||||
if data is None:
|
||||
error_count += 1
|
||||
if error_count > 5:
|
||||
self.logger.warning("Sensor read failed multiple times, retrying...")
|
||||
error_count = 0
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
error_count = 0
|
||||
current_level = data['level']
|
||||
rssi = data['rssi']
|
||||
|
||||
# 检测状态变化 (包括首次检测)
|
||||
if current_level != last_level:
|
||||
cycle_count += 1
|
||||
|
||||
if current_level:
|
||||
# 有液 -> 电机顺时针旋转
|
||||
self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), "
|
||||
f"rotating motor {motor_id} clockwise {pulses} pulses")
|
||||
motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False)
|
||||
|
||||
# 等待电机完成 (预估时间)
|
||||
estimated_time = 15.0 / max(1, speed)
|
||||
time.sleep(estimated_time + 0.5)
|
||||
|
||||
else:
|
||||
# 无液 -> 电机逆时针旋转
|
||||
self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), "
|
||||
f"rotating motor {motor_id} counter-clockwise {pulses} pulses")
|
||||
motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False)
|
||||
|
||||
# 等待电机完成 (预估时间)
|
||||
estimated_time = 15.0 / max(1, speed)
|
||||
time.sleep(estimated_time + 0.5)
|
||||
|
||||
# 更新状态
|
||||
last_level = current_level
|
||||
|
||||
# 轮询间隔
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
# 恢复轮询线程
|
||||
self.logger.info("Restarting polling thread...")
|
||||
self._start_polling()
|
||||
|
||||
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
|
||||
"""支持标准 JSON 指令调用"""
|
||||
return super().execute_command_from_outer(command_dict)
|
||||
|
||||
379
unilabos/devices/separator/xkc_sensor.py
Normal file
379
unilabos/devices/separator/xkc_sensor.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
XKC RS485 液位传感器 (Modbus RTU)
|
||||
|
||||
说明:
|
||||
1. 遵循 Modbus-RTU 协议。
|
||||
2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。
|
||||
3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。
|
||||
4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。
|
||||
"""
|
||||
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import serial
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||
|
||||
class TransportManager:
|
||||
"""
|
||||
统一通信管理类。
|
||||
仅支持 串口 (Serial/有线) 连接。
|
||||
"""
|
||||
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.logger = logger
|
||||
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
|
||||
|
||||
self.serial = None
|
||||
self._connect_serial()
|
||||
|
||||
def _connect_serial(self):
|
||||
try:
|
||||
self.serial = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=self.timeout
|
||||
)
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Serial open failed: {e}")
|
||||
|
||||
def close(self):
|
||||
"""关闭连接"""
|
||||
if self.serial and self.serial.is_open:
|
||||
self.serial.close()
|
||||
|
||||
def clear_buffer(self):
|
||||
"""清空缓冲区 (Thread-safe)"""
|
||||
with self.lock:
|
||||
if self.serial:
|
||||
self.serial.reset_input_buffer()
|
||||
|
||||
def write(self, data: bytes):
|
||||
"""发送原始字节"""
|
||||
with self.lock:
|
||||
if self.serial:
|
||||
self.serial.write(data)
|
||||
|
||||
def read(self, size: int) -> bytes:
|
||||
"""读取指定长度字节"""
|
||||
if self.serial:
|
||||
return self.serial.read(size)
|
||||
return b''
|
||||
|
||||
class XKCSensorDriver(UniversalDriver):
|
||||
"""XKC RS485 液位传感器 (Modbus RTU)"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6,
|
||||
threshold: int = 300, timeout: float = 3.0, debug: bool = False):
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.device_id = device_id
|
||||
self.threshold = threshold
|
||||
self.timeout = timeout
|
||||
self.debug = debug
|
||||
self.level = False
|
||||
self.rssi = 0
|
||||
self.status = {"level": self.level, "rssi": self.rssi}
|
||||
|
||||
try:
|
||||
self.transport = TransportManager(port, baudrate, timeout, logger=self.logger)
|
||||
self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to connect XKCSensorDriver: {e}")
|
||||
self.transport = None
|
||||
|
||||
# 启动背景轮询线程,确保 status 实时刷新
|
||||
self._stop_event = threading.Event()
|
||||
self._polling_thread = threading.Thread(
|
||||
target=self._update_loop,
|
||||
name=f"XKCPolling_{port}",
|
||||
daemon=True
|
||||
)
|
||||
if self.transport:
|
||||
self._polling_thread.start()
|
||||
|
||||
def _update_loop(self):
|
||||
"""背景循环读取传感器数据"""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self.read_level()
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
self.logger.error(f"Polling error: {e}")
|
||||
time.sleep(2.0) # 每2秒刷新一次数据
|
||||
|
||||
def _crc(self, data: bytes) -> bytes:
|
||||
crc = 0xFFFF
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
|
||||
else: crc >>= 1
|
||||
return struct.pack('<H', crc)
|
||||
|
||||
def read_level(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
读取液位。
|
||||
返回: {'level': bool, 'rssi': int}
|
||||
"""
|
||||
if not self.transport:
|
||||
return None
|
||||
|
||||
with self.transport.lock:
|
||||
self.transport.clear_buffer()
|
||||
# Modbus Read Registers: 01 03 00 01 00 02 CRC
|
||||
payload = struct.pack('>HH', 0x0001, 0x0002)
|
||||
msg = struct.pack('BB', self.device_id, 0x03) + payload
|
||||
msg += self._crc(msg)
|
||||
|
||||
if self.debug:
|
||||
self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}")
|
||||
|
||||
self.transport.write(msg)
|
||||
|
||||
# Read header
|
||||
h = self.transport.read(3) # Addr, Func, Len
|
||||
if self.debug:
|
||||
self.logger.info(f"RX Header: {h.hex().upper()}")
|
||||
|
||||
if len(h) < 3: return None
|
||||
length = h[2]
|
||||
|
||||
# Read body + CRC
|
||||
body = self.transport.read(length + 2)
|
||||
if self.debug:
|
||||
self.logger.info(f"RX Body+CRC: {body.hex().upper()}")
|
||||
if len(body) < length + 2:
|
||||
# Firmware bug fix specific to some modules
|
||||
if len(body) == 4 and length == 4:
|
||||
pass
|
||||
else:
|
||||
return None
|
||||
|
||||
data = body[:-2]
|
||||
# 根据手册说明:
|
||||
# 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液)
|
||||
# 寄存器 0x0002 (data[2:4]): 信号强度 RSSI
|
||||
|
||||
hw_level = False
|
||||
rssi = 0
|
||||
|
||||
if len(data) >= 4:
|
||||
hw_level = ((data[0] << 8) | data[1]) == 1
|
||||
rssi = (data[2] << 8) | data[3]
|
||||
elif len(data) == 2:
|
||||
# 兼容模式: 某些老固件可能只返回 1 个寄存器
|
||||
rssi = (data[0] << 8) | data[1]
|
||||
hw_level = rssi > self.threshold
|
||||
else:
|
||||
return None
|
||||
|
||||
# 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验
|
||||
# 注意: 如果用户显式设置了 THRESHOLD,我们可以在逻辑中做权衡
|
||||
self.level = hw_level or (rssi > self.threshold)
|
||||
self.rssi = rssi
|
||||
result = {
|
||||
'level': self.level,
|
||||
'rssi': self.rssi
|
||||
}
|
||||
self.status = result
|
||||
return result
|
||||
|
||||
def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool:
|
||||
"""
|
||||
等待液位达到目标状态 (阻塞式)
|
||||
"""
|
||||
self.logger.info(f"Waiting for level: {target_state}")
|
||||
start_time = time.time()
|
||||
while (time.time() - start_time) < timeout:
|
||||
res = self.read_level()
|
||||
if res and res.get('level') == target_state:
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
self.logger.warning(f"Wait level timeout ({timeout}s)")
|
||||
return False
|
||||
|
||||
def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool:
|
||||
"""
|
||||
实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。
|
||||
一旦检测到符合目标状态,立即返回。
|
||||
|
||||
Args:
|
||||
target_state: True 为“有液”, False 为“无液”
|
||||
timeout: 最大等待时间(秒)
|
||||
"""
|
||||
state_str = "有液" if target_state else "无液"
|
||||
self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)")
|
||||
|
||||
start_time = time.time()
|
||||
while (time.time() - start_time) < timeout:
|
||||
res = self.read_level() # 内部已更新 self.level 和 self.rssi
|
||||
if res:
|
||||
current_level = res.get('level')
|
||||
current_rssi = res.get('rssi')
|
||||
if current_level == target_state:
|
||||
self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})")
|
||||
return True
|
||||
|
||||
if self.debug:
|
||||
self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}")
|
||||
|
||||
time.sleep(0.2) # 高频采样
|
||||
|
||||
self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)")
|
||||
return False
|
||||
|
||||
def set_threshold(self, threshold: int):
|
||||
"""设置液位判定阈值"""
|
||||
self.threshold = int(threshold)
|
||||
self.logger.info(f"Threshold updated to: {self.threshold}")
|
||||
|
||||
def change_device_id(self, new_id: int) -> bool:
|
||||
"""
|
||||
修改设备的 Modbus 从站地址。
|
||||
寄存器: 0x0004, 功能码: 0x06
|
||||
"""
|
||||
if not (1 <= new_id <= 254):
|
||||
self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.")
|
||||
return False
|
||||
|
||||
self.logger.info(f"Changing device ID from {self.device_id} to {new_id}")
|
||||
success = self._write_single_register(0x0004, new_id)
|
||||
if success:
|
||||
self.device_id = new_id # 更新内存中的地址
|
||||
self.logger.info(f"Device ID update command sent successfully (target {new_id}).")
|
||||
return success
|
||||
|
||||
def change_baudrate(self, baud_code: int) -> bool:
|
||||
"""
|
||||
更改通讯波特率 (寄存器: 0x0005)。
|
||||
设置成功后传感器 LED 会闪烁,通常无数据返回。
|
||||
|
||||
波特率代码对照表 (16进制):
|
||||
05: 2400
|
||||
06: 4800
|
||||
07: 9600 (默认)
|
||||
08: 14400
|
||||
09: 19200
|
||||
0A: 28800
|
||||
0C: 57600
|
||||
0D: 115200
|
||||
0E: 128000
|
||||
0F: 256000
|
||||
"""
|
||||
self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})")
|
||||
# 写入寄存器 0x0005
|
||||
self._write_single_register(0x0005, baud_code)
|
||||
self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.")
|
||||
return True
|
||||
|
||||
def factory_reset(self) -> bool:
|
||||
"""
|
||||
恢复出厂设置 (通过广播地址 FF)。
|
||||
设置地址为 01,逻辑为向 0x0004 写入 0x0002
|
||||
"""
|
||||
self.logger.info("Sending factory reset command via broadcast address FF...")
|
||||
# 广播指令通常无回显
|
||||
self._write_single_register(0x0004, 0x0002, slave_id=0xFF)
|
||||
self.logger.info("Factory reset command sent. Device address should be 01 now.")
|
||||
return True
|
||||
|
||||
def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool:
|
||||
"""内部辅助函数: Modbus 功能码 06 写单个寄存器"""
|
||||
if not self.transport: return False
|
||||
|
||||
target_id = slave_id if slave_id is not None else self.device_id
|
||||
msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value)
|
||||
msg += self._crc(msg)
|
||||
|
||||
with self.transport.lock:
|
||||
self.transport.clear_buffer()
|
||||
if self.debug:
|
||||
self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}")
|
||||
|
||||
self.transport.write(msg)
|
||||
|
||||
# 广播地址、波特率修改或厂家特定指令可能无回显
|
||||
if target_id == 0xFF or reg_addr == 0x0005:
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
|
||||
# 等待返回 (正常应返回相同报文)
|
||||
resp = self.transport.read(len(msg))
|
||||
if self.debug:
|
||||
self.logger.info(f"RX Write Response: {resp.hex().upper()}")
|
||||
|
||||
return resp == msg
|
||||
|
||||
def close(self):
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 快速实例化测试
|
||||
import logging
|
||||
# 减少冗余日志,仅显示重要信息
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
|
||||
# 硬件配置 (根据实际情况修改)
|
||||
TEST_PORT = "/dev/tty.usbserial-3110"
|
||||
SLAVE_ID = 1
|
||||
THRESHOLD = 300
|
||||
|
||||
print("\n" + "="*50)
|
||||
print(f" XKC RS485 传感器独立测试程序")
|
||||
print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}")
|
||||
print("="*50)
|
||||
|
||||
sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False)
|
||||
|
||||
try:
|
||||
if sensor.transport:
|
||||
print(f"\n开始实时连续采样测试 (持续 15 秒)...")
|
||||
print(f"按 Ctrl+C 可提前停止\n")
|
||||
|
||||
start_time = time.time()
|
||||
duration = 15
|
||||
count = 0
|
||||
|
||||
while time.time() - start_time < duration:
|
||||
count += 1
|
||||
res = sensor.read_level()
|
||||
if res:
|
||||
rssi = res['rssi']
|
||||
level = res['level']
|
||||
status_str = "【有液】" if level else "【无液】"
|
||||
# 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史)
|
||||
# 为了方便查看变化,我们直接打印
|
||||
elapsed = time.time() - start_time
|
||||
print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}")
|
||||
else:
|
||||
print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)")
|
||||
|
||||
time.sleep(0.5) # 每秒采样 2 次
|
||||
|
||||
print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---")
|
||||
|
||||
# [3] 测试动态修改阈值
|
||||
print(f"\n[3] 动态修改阈值演示...")
|
||||
new_threshold = 400
|
||||
sensor.set_threshold(new_threshold)
|
||||
res = sensor.read_level()
|
||||
if res:
|
||||
print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}")
|
||||
sensor.set_threshold(THRESHOLD) # 还原
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[!] 用户中断测试")
|
||||
except Exception as e:
|
||||
print(f"\n[!] 测试运行出错: {e}")
|
||||
finally:
|
||||
sensor.close()
|
||||
print("\n--- 测试程序已退出 ---\n")
|
||||
@@ -1,9 +1,5 @@
|
||||
# 工作站抽象基类物料系统架构说明
|
||||
|
||||
## 设计理念
|
||||
|
||||
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
||||
|
||||
### 核心原则
|
||||
|
||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Bioyond Cell 工作站 - 多订单返回示例
|
||||
|
||||
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
|
||||
|
||||
## 问题描述
|
||||
|
||||
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
|
||||
|
||||
## 解决方案
|
||||
|
||||
修改后的 `create_orders` 函数现在会:
|
||||
|
||||
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
|
||||
2. **逐个等待完成**:遍历所有 orderCode,调用 `wait_for_order_finish` 等待每个订单完成
|
||||
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
|
||||
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
|
||||
|
||||
## 返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "all_completed",
|
||||
"total_orders": 3,
|
||||
"reports": [
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:32:09.2148671+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
|
||||
"orderCode": "BSO2025122400024",
|
||||
"orderName": "DP20251224001",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
},
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:32:09.9999039+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
|
||||
"orderCode": "BSO2025122400025",
|
||||
"orderName": "DP20251224002",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
},
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:34:00.4139986+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
|
||||
"orderCode": "BSO2025122400026",
|
||||
"orderName": "DP20251224003",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
}
|
||||
],
|
||||
"original_response": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```python
|
||||
# 调用 create_orders
|
||||
result = workstation.create_orders("20251224.xlsx")
|
||||
|
||||
# 访问返回数据
|
||||
print(f"总订单数: {result['total_orders']}")
|
||||
print(f"状态: {result['status']}")
|
||||
|
||||
# 遍历所有订单的报文
|
||||
for i, report in enumerate(result['reports'], 1):
|
||||
order_data = report.get('data', {})
|
||||
print(f"\n订单 {i}:")
|
||||
print(f" orderCode: {order_data.get('orderCode')}")
|
||||
print(f" orderName: {order_data.get('orderName')}")
|
||||
print(f" status: {order_data.get('status')}")
|
||||
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
|
||||
```
|
||||
|
||||
## 控制台输出示例
|
||||
|
||||
```
|
||||
[create_orders] 即将提交订单数量: 3
|
||||
[create_orders] 接口返回: {...}
|
||||
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
|
||||
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
|
||||
[create_orders] ✓ 订单 BSO2025122400024 完成
|
||||
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
|
||||
[create_orders] ✓ 订单 BSO2025122400025 完成
|
||||
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
|
||||
[create_orders] ✓ 订单 BSO2025122400026 完成
|
||||
[create_orders] 所有订单已完成,共收集 3 个报文
|
||||
实验记录本========================create_orders========================
|
||||
返回报文数量: 3
|
||||
报文 1: orderCode=BSO2025122400024, status=30
|
||||
报文 2: orderCode=BSO2025122400025, status=30
|
||||
报文 3: orderCode=BSO2025122400026, status=30
|
||||
========================
|
||||
```
|
||||
|
||||
## 关键改进
|
||||
|
||||
1. ✅ **等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
|
||||
2. ✅ **收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
|
||||
3. ✅ **详细日志**:清晰显示正在等待哪个订单,以及完成情况
|
||||
4. ✅ **错误处理**:即使某个订单失败,也会记录其状态信息
|
||||
5. ✅ **统一格式**:返回的 JSON 格式便于后续处理和分析
|
||||
Binary file not shown.
@@ -0,0 +1,204 @@
|
||||
# BioyondCellWorkstation JSON 配置迁移经验总结
|
||||
|
||||
**日期**: 2026-01-13
|
||||
**目的**: 从 `config.py` 迁移到 JSON 配置文件
|
||||
|
||||
---
|
||||
|
||||
## 问题背景
|
||||
|
||||
原系统通过 `config.py` 管理配置,导致:
|
||||
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
|
||||
2. 配置分散在代码中,不便于管理
|
||||
3. 无法通过 JSON 统一配置所有参数
|
||||
|
||||
---
|
||||
|
||||
## 解决方案:嵌套配置结构
|
||||
|
||||
### JSON 结构设计
|
||||
|
||||
**正确示例** (嵌套在 `config` 中):
|
||||
```json
|
||||
{
|
||||
"nodes": [{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"protocol_type": [],
|
||||
"bioyond_config": {
|
||||
"api_host": "http://172.16.11.219:44388",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"HTTP_host": "172.16.11.206",
|
||||
"HTTP_port": 8080,
|
||||
"debug_mode": false,
|
||||
"material_type_mappings": {...},
|
||||
"warehouse_mapping": {...},
|
||||
"solid_liquid_mappings": {...}
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ `bioyond_config` 放在 `config` 中(会传递到 `__init__`)
|
||||
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
|
||||
|
||||
---
|
||||
|
||||
## Python 代码适配
|
||||
|
||||
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
|
||||
|
||||
**文件**: `bioyond_cell_workstation.py`
|
||||
|
||||
```python
|
||||
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
bioyond_config: 从 JSON 加载的配置字典
|
||||
deck: Deck 配置
|
||||
protocol_type: 协议类型
|
||||
"""
|
||||
# 验证配置
|
||||
if bioyond_config is None:
|
||||
raise ValueError("需要 bioyond_config 参数")
|
||||
|
||||
# 保存配置
|
||||
self.bioyond_config = bioyond_config
|
||||
|
||||
# 设置 HTTP 服务去重标志
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
# 调用父类
|
||||
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||
```
|
||||
|
||||
### 2. 替换全局变量引用
|
||||
|
||||
**修改前**(使用全局变量):
|
||||
```python
|
||||
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
|
||||
|
||||
def create_sample(self, board_type, ...):
|
||||
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
|
||||
```
|
||||
|
||||
**修改后**(从配置读取):
|
||||
```python
|
||||
def create_sample(self, board_type, ...):
|
||||
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
|
||||
```
|
||||
|
||||
### 3. 修复父类配置访问
|
||||
|
||||
在 `station.py` 中安全访问配置默认值:
|
||||
|
||||
```python
|
||||
# 修改前(会 KeyError)
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
|
||||
}
|
||||
|
||||
# 修改后(安全访问)
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
### ❌ 错误1:将配置放在 `data` 字段
|
||||
```json
|
||||
"config": {"deck": {...}},
|
||||
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
|
||||
```
|
||||
|
||||
### ❌ 错误2:扁平化配置(已废弃方案)
|
||||
虽然扁平化也能工作,但不推荐:
|
||||
```json
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"api_host": "...", // ❌ 不够清晰
|
||||
"api_key": "...",
|
||||
"HTTP_host": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ 错误3:忘记替换全局变量引用
|
||||
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`。
|
||||
|
||||
---
|
||||
|
||||
## 云端同步注意事项
|
||||
|
||||
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
|
||||
- 首次上传时确保 JSON 完整
|
||||
- 或使用新的 `ak/sk` 避免旧配置干扰
|
||||
- 调试时可暂时移除 `--upload_registry` 参数
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
启动成功后应看到:
|
||||
```
|
||||
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||
API Host: http://...
|
||||
HTTP Service: ...
|
||||
✅ BioyondCellWorkstation 初始化完成
|
||||
Loaded ResourceTreeSet with ... nodes
|
||||
```
|
||||
|
||||
运行时不应出现:
|
||||
- ❌ `NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
|
||||
- ❌ `KeyError: 'http_service_host'`
|
||||
- ❌ `bioyond_config 缺少必需参数`
|
||||
|
||||
---
|
||||
|
||||
## 调试经验
|
||||
|
||||
1. **添加调试日志**查看参数传递链路:
|
||||
- `graphio.py`: JSON 加载后的 config 内容
|
||||
- `initialize_device.py`: `device_config.res_content.config` 的键
|
||||
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
|
||||
|
||||
2. **config vs data 区别**:
|
||||
- `config`: 初始化参数,传递给 `__init__`
|
||||
- `data`: 运行时状态,不传递给 `__init__`
|
||||
|
||||
3. **参数名必须匹配**:
|
||||
- JSON 中的键名必须与 `__init__` 参数名完全一致
|
||||
|
||||
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
|
||||
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
|
||||
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
|
||||
|
||||
---
|
||||
|
||||
## 参考代码位置
|
||||
|
||||
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
|
||||
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
|
||||
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
|
||||
- HTTP 服务配置: `station.py` L629-L634
|
||||
|
||||
---
|
||||
|
||||
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。
|
||||
@@ -0,0 +1,312 @@
|
||||
# BioyondCell 配置迁移修改总结
|
||||
|
||||
**日期**: 2026-01-13
|
||||
**目标**: 从 `config.py` 完全迁移到 JSON 配置,消除所有全局变量依赖
|
||||
|
||||
---
|
||||
|
||||
## 📋 修改概览
|
||||
|
||||
本次修改完成了 BioyondCell 模块从 Python 配置文件到 JSON 配置的完整迁移,并清理了所有对 `config.py` 全局变量的依赖。
|
||||
|
||||
### 核心成果
|
||||
|
||||
- ✅ 完全移除对 `config.py` 的导入依赖
|
||||
- ✅ 使用嵌套 JSON 结构 `config.bioyond_config`
|
||||
- ✅ 修复 7 处 `bioyond_cell_workstation.py` 中的全局变量引用
|
||||
- ✅ 修复 3 处其他文件中的全局变量引用
|
||||
- ✅ HTTP 服务去重机制完善
|
||||
- ✅ 系统成功启动并正常运行
|
||||
|
||||
---
|
||||
|
||||
## 🔧 修改文件清单
|
||||
|
||||
### 1. JSON 配置文件
|
||||
|
||||
**文件**: `yibin_electrolyte_config.json`
|
||||
|
||||
**修改**:
|
||||
- 采用嵌套结构将所有配置放在 `config.bioyond_config` 中
|
||||
- 包含:`api_host`, `api_key`, `HTTP_host`, `HTTP_port`, `material_type_mappings`, `warehouse_mapping`, `solid_liquid_mappings` 等
|
||||
|
||||
**示例结构**:
|
||||
```json
|
||||
{
|
||||
"nodes": [{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"protocol_type": [],
|
||||
"bioyond_config": {
|
||||
"api_host": "http://172.16.11.219:44388",
|
||||
"api_key": "8A819E5C",
|
||||
"HTTP_host": "172.16.11.206",
|
||||
"HTTP_port": 8080,
|
||||
"material_type_mappings": {...},
|
||||
"warehouse_mapping": {...},
|
||||
"solid_liquid_mappings": {...}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. bioyond_cell_workstation.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
#### 修改 A: `__init__` 方法签名 (L39-99)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def __init__(self, deck=None, protocol_type=None, **kwargs):
|
||||
# 从 kwargs 收集配置字段
|
||||
self.bioyond_config = {}
|
||||
for field in bioyond_field_names:
|
||||
if field in kwargs:
|
||||
self.bioyond_config[field] = kwargs.pop(field)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||
"""直接接收 bioyond_config 参数"""
|
||||
if bioyond_config is None:
|
||||
raise ValueError("需要 bioyond_config 参数")
|
||||
|
||||
self.bioyond_config = bioyond_config
|
||||
|
||||
# 设置 HTTP 服务去重标志
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||
```
|
||||
|
||||
#### 修改 B: 替换全局变量引用 (7 处)
|
||||
|
||||
| 位置 | 原代码 | 修改后 |
|
||||
|------|--------|--------|
|
||||
| L2005 | `MATERIAL_TYPE_MAPPINGS[board_type][1]` | `self.bioyond_config['material_type_mappings'][board_type][1]` |
|
||||
| L2006 | `MATERIAL_TYPE_MAPPINGS[bottle_type][1]` | `self.bioyond_config['material_type_mappings'][bottle_type][1]` |
|
||||
| L2009 | `WAREHOUSE_MAPPING` | `self.bioyond_config['warehouse_mapping']` |
|
||||
| L2013 | `WAREHOUSE_MAPPING[warehouse_name]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]` |
|
||||
| L2017 | `WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"]` |
|
||||
| L1863 | `SOLID_LIQUID_MAPPINGS.get(material_name)` | `self.bioyond_config.get('solid_liquid_mappings', {}).get(material_name)` |
|
||||
| L1966, L1976 | `MATERIAL_TYPE_MAPPINGS.items()` | `self.bioyond_config['material_type_mappings'].items()` |
|
||||
|
||||
---
|
||||
|
||||
### 3. station.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||
|
||||
#### 修改 A: 删除 config 导入 (L26-28)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||
)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
# 已删除此导入
|
||||
```
|
||||
|
||||
#### 修改 B: `_create_communication_module` 方法 (L691-702)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
default_config = {
|
||||
**API_CONFIG,
|
||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||
}
|
||||
if config:
|
||||
self.bioyond_config = {**default_config, **config}
|
||||
else:
|
||||
self.bioyond_config = default_config
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""创建Bioyond通信模块"""
|
||||
# 使用传入的 config 参数(来自 bioyond_config)
|
||||
# 不再依赖全局变量 API_CONFIG 等
|
||||
if config:
|
||||
self.bioyond_config = config
|
||||
else:
|
||||
# 如果没有传入配置,创建空配置(用于测试或兼容性)
|
||||
self.bioyond_config = {}
|
||||
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
```
|
||||
|
||||
#### 修改 C: HTTP 服务配置 (L627-632)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", "")),
|
||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG.get("http_service_port", 0))
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", bioyond_config.get("HTTP_host", "")),
|
||||
"port": bioyond_config.get("http_service_port", bioyond_config.get("HTTP_port", 0))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. bioyond_rpc.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`
|
||||
|
||||
#### 修改 A: 删除 config 导入 (L12)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
# 已删除此导入
|
||||
```
|
||||
|
||||
#### 修改 B: `material_outbound` 方法 (L278-280)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
# location_name 参数实际上应该直接是 location_id (UUID)
|
||||
location_id = location_name
|
||||
```
|
||||
|
||||
**说明**: `LOCATION_MAPPING` 在 `config-0113.py` 中本来就是空字典 `{}`,所以直接使用 `location_name` 逻辑等价。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 关键设计决策
|
||||
|
||||
### 1. 嵌套 vs 扁平配置
|
||||
|
||||
**选择**: 嵌套结构 `config.bioyond_config`
|
||||
|
||||
**理由**:
|
||||
- ✅ 语义清晰,配置分组明确
|
||||
- ✅ 参数传递直观,直接对应 `__init__` 参数
|
||||
- ✅ 易于维护,不需要硬编码字段列表
|
||||
- ✅ 符合 UniLab 设计模式
|
||||
|
||||
### 2. HTTP 服务去重
|
||||
|
||||
**实现**: 子类设置 `_disable_auto_http_service` 标志
|
||||
|
||||
```python
|
||||
# bioyond_cell_workstation.py
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
# station.py (post_init)
|
||||
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||
logger.info("子类已自行管理HTTP服务,跳过自动启动")
|
||||
return
|
||||
```
|
||||
|
||||
### 3. 全局变量替换策略
|
||||
|
||||
**原则**: 所有配置从 `self.bioyond_config` 获取
|
||||
|
||||
**模式**:
|
||||
```python
|
||||
# 修改前
|
||||
from config import MATERIAL_TYPE_MAPPINGS
|
||||
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||
|
||||
# 修改后
|
||||
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证结果
|
||||
|
||||
### 启动成功日志
|
||||
```
|
||||
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||
API Host: http://172.16.11.219:44388
|
||||
HTTP Service: 172.16.11.206:8080
|
||||
🔧 已设置 _disable_auto_http_service 标志,防止 HTTP 服务重复启动
|
||||
✅ BioyondCellWorkstation 初始化完成
|
||||
Loaded ResourceTreeSet with 1 trees, 1785 total nodes
|
||||
```
|
||||
|
||||
### 功能验证
|
||||
- ✅ 订单创建 (`create_orders_v2`)
|
||||
- ✅ 质量比计算
|
||||
- ✅ 物料转移 (`transfer_3_to_2_to_1`)
|
||||
- ✅ HTTP 报送接收 (step_finish, sample_finish, order_finish)
|
||||
- ✅ 等待机制 (`wait_for_order_finish`)
|
||||
- ✅ 仓库 UUID 映射
|
||||
- ✅ 物料类型映射
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **配置迁移经验**: `2026-01-13_JSON配置迁移经验.md`
|
||||
- **任务清单**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\task.md`
|
||||
- **实施计划**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\implementation_plan.md`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 其他工作站模块
|
||||
|
||||
以下文件仍在使用 `config.py` 全局变量(未包含在本次修改中):
|
||||
- `reaction_station.py` - 使用 `API_CONFIG`
|
||||
- `experiment.py` - 使用 `API_CONFIG`, `WORKFLOW_MAPPINGS`, `MATERIAL_TYPE_MAPPINGS`
|
||||
- `dispensing_station.py` - 使用 `API_CONFIG`, `WAREHOUSE_MAPPING`
|
||||
- `station.py` L176, L177, L529, L530 - 动态导入 `WAREHOUSE_MAPPING`
|
||||
|
||||
**建议**: 后续可以统一迁移这些模块到 JSON 配置。
|
||||
|
||||
### config.py 文件
|
||||
|
||||
`config.py` 文件已恢复但**不再被 bioyond_cell 使用**。可以:
|
||||
- 保留作为其他模块的参考
|
||||
- 或者完全删除(如果其他模块也迁移完成)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 下一步建议
|
||||
|
||||
1. **清理调试代码** ✅ (已完成)
|
||||
2. **提交代码到 Git**
|
||||
3. **迁移其他工作站模块** (可选)
|
||||
4. **更新文档和启动脚本**
|
||||
|
||||
---
|
||||
|
||||
**修改完成日期**: 2026-01-13
|
||||
**系统状态**: ✅ 稳定运行
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,157 @@
|
||||
# 批量出库 Excel 模板使用说明
|
||||
|
||||
**文件**: `outbound_template.xlsx`
|
||||
**用途**: 配合 `auto_batch_outbound_from_xlsx()` 方法进行批量出库操作
|
||||
**API 端点**: `/api/lims/storage/auto-batch-out-bound`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Excel 列说明
|
||||
|
||||
| 列名 | 说明 | 示例 | 必填 |
|
||||
|------|------|------|------|
|
||||
| `locationId` | **库位 ID(UUID)** | `3a19da43-57b5-294f-d663-154a1cc32270` | ✅ 是 |
|
||||
| `warehouseId` | **仓库 ID 或名称** | `配液站内试剂仓库` | ✅ 是 |
|
||||
| `quantity` | **出库数量** | `1.0`, `2.0` | ✅ 是 |
|
||||
| `x` | **X 坐标(库位横向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `y` | **Y 坐标(库位纵向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `z` | **Z 坐标(库位层数/高度)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `备注说明` | 可选备注信息 | `配液站内试剂仓库-A01` | ❌ 否 |
|
||||
|
||||
### 📐 坐标说明
|
||||
|
||||
**x, y, z** 是库位在仓库内的**三维坐标**:
|
||||
|
||||
```
|
||||
仓库(例如 WH4)
|
||||
├── Z=1(第1层/加样头面)
|
||||
│ ├── X=1, Y=1(位置 A)
|
||||
│ ├── X=2, Y=1(位置 B)
|
||||
│ ├── X=3, Y=1(位置 C)
|
||||
│ └── ...
|
||||
│
|
||||
└── Z=2(第2层/原液瓶面)
|
||||
├── X=1, Y=1(位置 A)
|
||||
├── X=2, Y=1(位置 B)
|
||||
└── ...
|
||||
```
|
||||
|
||||
- **warehouseId**: 指定哪个仓库(WH3, WH4, 配液站等)
|
||||
- **x, y, z**: 在该仓库内的三维坐标
|
||||
- **locationId**: 该坐标位置的唯一 UUID
|
||||
|
||||
### 🎯 起点与终点
|
||||
|
||||
**重要说明**:批量出库模板**只规定了出库的"起点"**(从哪里取物料),**没有指定"终点"**(放到哪里)。
|
||||
|
||||
```
|
||||
出库流程:
|
||||
起点(Excel 指定) → ?终点(LIMS/工作流决定)
|
||||
↓
|
||||
locationId, x, y, z → 由 LIMS 系统或当前工作流自动分配
|
||||
```
|
||||
|
||||
**终点由以下方式确定:**
|
||||
- **LIMS 系统自动分配**:根据当前任务自动规划目标位置
|
||||
- **工作流预定义**:在创建出库任务时已绑定目标位置
|
||||
- **暂存区**:默认放到出库暂存区,等待下一步操作
|
||||
|
||||
💡 **对比**:上料操作(`auto_feeding4to3`)则有 `targetWH` 参数可以指定目标仓库
|
||||
|
||||
---
|
||||
|
||||
## 🔍 如何获取 UUID?
|
||||
|
||||
### 方法 1:从配置文件获取
|
||||
|
||||
参考 `yibin_electrolyte_config.json` 中的 `warehouse_mapping`:
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"配液站内试剂仓库": {
|
||||
"site_uuids": {
|
||||
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
|
||||
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
|
||||
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f"
|
||||
}
|
||||
},
|
||||
"手动堆栈": {
|
||||
"site_uuids": {
|
||||
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 方法 2:通过 API 查询
|
||||
|
||||
```python
|
||||
material_info = hardware_interface.material_id_query(workflow_id)
|
||||
locations = material_info.get("locations", [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 填写示例
|
||||
|
||||
### 示例 1:从配液站内试剂仓库出库
|
||||
|
||||
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||
|------------|-------------|----------|---|---|---|----------|
|
||||
| `3a19da43-57b5-294f-d663-154a1cc32270` | 配液站内试剂仓库 | 1 | 1 | 1 | 1 | A01 位置 |
|
||||
| `3a19da43-57b5-7394-5f49-54efe2c9bef2` | 配液站内试剂仓库 | 2 | 2 | 1 | 1 | B01 位置 |
|
||||
|
||||
### 示例 2:从手动堆栈出库
|
||||
|
||||
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||
|------------|-------------|----------|---|---|---|----------|
|
||||
| `3a19deae-2c7a-36f5-5e41-02c5b66feaea` | 手动堆栈 | 1 | 1 | 1 | 1 | A01 |
|
||||
| `3a19deae-2c7a-dc6d-c41e-ef285d946cfe` | 手动堆栈 | 1 | 1 | 2 | 1 | A02 |
|
||||
|
||||
---
|
||||
|
||||
## 💻 使用方法
|
||||
|
||||
```python
|
||||
from bioyond_cell_workstation import BioyondCellWorkstation
|
||||
|
||||
# 初始化工作站
|
||||
workstation = BioyondCellWorkstation(config=config, deck=deck)
|
||||
|
||||
# 调用批量出库方法
|
||||
result = workstation.auto_batch_outbound_from_xlsx(
|
||||
xlsx_path="outbound_template.xlsx"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **locationId 必须是有效的 UUID**,不能使用库位名称
|
||||
2. **x, y, z 坐标必须与 locationId 对应**,表示该库位在仓库内的位置
|
||||
3. **quantity 必须是数字**,可以是整数或浮点数
|
||||
4. Excel 文件必须包含表头行
|
||||
5. 空行会被自动跳过
|
||||
6. 确保 UUID 与实际库位对应,否则 API 会报错
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文件
|
||||
|
||||
- **配置文件**: `yibin_electrolyte_config.json`
|
||||
- **Python 代码**: `bioyond_cell_workstation.py` (L630-695)
|
||||
- **生成脚本**: `create_outbound_template.py`
|
||||
- **上料模板**: `material_template.xlsx`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 重新生成模板
|
||||
|
||||
```bash
|
||||
conda activate newunilab
|
||||
python create_outbound_template.py
|
||||
```
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||
|
||||
|
||||
|
||||
class SimpleLogger:
|
||||
@@ -49,6 +49,14 @@ class BioyondV1RPC(BaseRequest):
|
||||
self.config = config
|
||||
self.api_key = config["api_key"]
|
||||
self.host = config["api_host"]
|
||||
|
||||
# 初始化 location_mapping
|
||||
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
|
||||
self.location_mapping = {}
|
||||
warehouse_mapping = self.config.get("warehouse_mapping", {})
|
||||
for warehouse_name, warehouse_config in warehouse_mapping.items():
|
||||
if "site_uuids" in warehouse_config:
|
||||
self.location_mapping.update(warehouse_config["site_uuids"])
|
||||
self._logger = SimpleLogger()
|
||||
self.material_cache = {}
|
||||
self._load_material_cache()
|
||||
@@ -176,7 +184,40 @@ class BioyondV1RPC(BaseRequest):
|
||||
return {}
|
||||
|
||||
print(f"add material data: {response['data']}")
|
||||
return response.get("data", {})
|
||||
|
||||
# 自动更新缓存
|
||||
data = response.get("data", {})
|
||||
if data:
|
||||
if isinstance(data, str):
|
||||
# 如果返回的是字符串,通常是ID
|
||||
mat_id = data
|
||||
name = params.get("name")
|
||||
else:
|
||||
# 如果返回的是字典,尝试获取name和id
|
||||
name = data.get("name") or params.get("name")
|
||||
mat_id = data.get("id")
|
||||
|
||||
if name and mat_id:
|
||||
self.material_cache[name] = mat_id
|
||||
print(f"已自动更新缓存: {name} -> {mat_id}")
|
||||
|
||||
# 处理返回数据中的 details (如果有)
|
||||
# 有些 API 返回结构可能直接包含 details,或者在 data 字段中
|
||||
details = data.get("details", []) if isinstance(data, dict) else []
|
||||
if not details and isinstance(data, dict):
|
||||
details = data.get("detail", [])
|
||||
|
||||
if details:
|
||||
for detail in details:
|
||||
d_name = detail.get("name")
|
||||
# 尝试从不同字段获取 ID
|
||||
d_id = detail.get("id") or detail.get("detailMaterialId")
|
||||
|
||||
if d_name and d_id:
|
||||
self.material_cache[d_name] = d_id
|
||||
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
|
||||
|
||||
return data
|
||||
|
||||
def query_matial_type_id(self, data) -> list:
|
||||
"""查找物料typeid"""
|
||||
@@ -203,7 +244,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": {},
|
||||
"data": 0,
|
||||
})
|
||||
if not response or response['code'] != 1:
|
||||
return []
|
||||
@@ -273,11 +314,19 @@ class BioyondV1RPC(BaseRequest):
|
||||
|
||||
if not response or response['code'] != 1:
|
||||
return {}
|
||||
|
||||
# 自动更新缓存 - 移除被删除的物料
|
||||
for name, mid in list(self.material_cache.items()):
|
||||
if mid == material_id:
|
||||
del self.material_cache[name]
|
||||
print(f"已从缓存移除物料: {name}")
|
||||
break
|
||||
|
||||
return response.get("data", {})
|
||||
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||
location_id = self.location_mapping.get(location_name, location_name)
|
||||
|
||||
params = {
|
||||
"materialId": material_id,
|
||||
@@ -1103,6 +1152,10 @@ class BioyondV1RPC(BaseRequest):
|
||||
for detail_material in detail_materials:
|
||||
detail_name = detail_material.get("name")
|
||||
detail_id = detail_material.get("detailMaterialId")
|
||||
if not detail_id:
|
||||
# 尝试其他可能的字段
|
||||
detail_id = detail_material.get("id")
|
||||
|
||||
if detail_name and detail_id:
|
||||
self.material_cache[detail_name] = detail_id
|
||||
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
||||
@@ -1123,6 +1176,14 @@ class BioyondV1RPC(BaseRequest):
|
||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
# 如果缓存中没有,尝试刷新缓存
|
||||
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
||||
self.refresh_material_cache()
|
||||
if material_name_or_id in self.material_cache:
|
||||
material_id = self.material_cache[material_name_or_id]
|
||||
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||
return material_name_or_id
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# config.py
|
||||
"""
|
||||
配置文件 - 包含所有配置信息和映射关系
|
||||
"""
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
"api_key": "",
|
||||
"api_host": ""
|
||||
}
|
||||
|
||||
# 工作流映射配置
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "",
|
||||
"reactor_taken_in": "",
|
||||
"Solid_feeding_vials": "",
|
||||
"Liquid_feeding_vials(non-titration)": "",
|
||||
"Liquid_feeding_solvents": "",
|
||||
"Liquid_feeding(titration)": "",
|
||||
"liquid_feeding_beaker": "",
|
||||
"Drip_back": "",
|
||||
}
|
||||
|
||||
# 工作流名称到DisplaySectionName的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'reactor_taken_out': '反应器取出'
|
||||
}
|
||||
|
||||
# 库位映射配置
|
||||
WAREHOUSE_MAPPING = {
|
||||
"粉末堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
# 样品板
|
||||
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
# 分装板
|
||||
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
||||
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
||||
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
||||
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
||||
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
||||
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
||||
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
||||
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 物料类型配置
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||
}
|
||||
|
||||
# 步骤参数配置(各工作流的步骤UUID)
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": ""
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
}
|
||||
}
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
|
||||
ACTION_NAMES = {}
|
||||
|
||||
HTTP_SERVICE_CONFIG = {}
|
||||
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
@@ -0,0 +1,329 @@
|
||||
# config.py
|
||||
"""
|
||||
Bioyond工作站配置文件
|
||||
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
|
||||
"""
|
||||
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||
|
||||
# ============================================================================
|
||||
# 基础配置
|
||||
# ============================================================================
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://192.168.1.200:44402"
|
||||
}
|
||||
|
||||
# HTTP 报送服务配置
|
||||
HTTP_SERVICE_CONFIG = {
|
||||
"http_service_host": "127.0.0.1", # 监听地址
|
||||
"http_service_port": 8080, # 监听端口
|
||||
}
|
||||
|
||||
# Deck配置 - 反应站工作台配置
|
||||
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
|
||||
|
||||
# ============================================================================
|
||||
# 工作流配置
|
||||
# ============================================================================
|
||||
|
||||
# 工作流ID映射
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
|
||||
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
|
||||
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
|
||||
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
|
||||
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
|
||||
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
|
||||
}
|
||||
|
||||
# 工作流名称到显示名称的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'reactor_taken_out': '反应器取出',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Drip_back': '液体回滴'
|
||||
}
|
||||
|
||||
# 工作流步骤ID配置
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
|
||||
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
|
||||
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
|
||||
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
|
||||
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
|
||||
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
|
||||
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
|
||||
}
|
||||
}
|
||||
|
||||
# 工作流动作名称配置
|
||||
ACTION_NAMES = {
|
||||
"reactor_taken_in": {
|
||||
"config": "通量-配置",
|
||||
"stirring": "反应模块-开始搅拌"
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "粉末加样模块-投料",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "稀释液瓶加液位-液体投料",
|
||||
"observe": "反应模块-滴定结果观察"
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "稀释液瓶加液位-稀释液吸液分液",
|
||||
"observe": "反应模块-滴定结果观察"
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "烧杯溶液放置位-烧杯吸液分液",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||
"observe": "反应模块-向下滴定结果观察"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 仓库配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 出库和入库操作都需要UUID
|
||||
WAREHOUSE_MAPPING = {
|
||||
# ========== 反应站仓库 ==========
|
||||
|
||||
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01~D04)
|
||||
"堆栈1左": {
|
||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
||||
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
||||
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
|
||||
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
|
||||
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
||||
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
|
||||
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
|
||||
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
|
||||
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
|
||||
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
|
||||
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
|
||||
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
|
||||
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
|
||||
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
|
||||
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
|
||||
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
|
||||
},
|
||||
},
|
||||
|
||||
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05~D08)
|
||||
"堆栈1右": {
|
||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"site_uuids": {
|
||||
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
|
||||
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
|
||||
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
|
||||
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
|
||||
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
|
||||
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
|
||||
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
|
||||
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
|
||||
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
|
||||
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
|
||||
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
|
||||
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
|
||||
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
|
||||
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
|
||||
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
|
||||
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
|
||||
},
|
||||
},
|
||||
|
||||
# 站内试剂存放堆栈
|
||||
"站内试剂存放堆栈": {
|
||||
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
||||
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
# 测量小瓶仓库(测密度)
|
||||
"测量小瓶仓库": {
|
||||
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
|
||||
"site_uuids": {
|
||||
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
|
||||
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
|
||||
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
|
||||
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
|
||||
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
|
||||
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
|
||||
}
|
||||
},
|
||||
|
||||
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
|
||||
"站内Tip盒堆栈": {
|
||||
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
|
||||
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
|
||||
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
|
||||
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
|
||||
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
|
||||
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
|
||||
}
|
||||
},
|
||||
# ========== 配液站仓库 ==========
|
||||
"粉末堆栈": {
|
||||
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
|
||||
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
|
||||
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
|
||||
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
|
||||
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
|
||||
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
|
||||
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
|
||||
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 物料类型配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
|
||||
# - 这个映射基于 resource.model 属性 (不是显示名称!)
|
||||
# - UUID为空表示该类型暂未在Bioyond系统中定义
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
# ================================================配液站资源============================================================
|
||||
# ==================================================样品===============================================================
|
||||
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
|
||||
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
|
||||
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
|
||||
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
|
||||
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
|
||||
# ==================================================试剂===============================================================
|
||||
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板(8孔)
|
||||
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
|
||||
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 动态生成的库位UUID映射(从WAREHOUSE_MAPPING中提取)
|
||||
# ============================================================================
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
|
||||
if "site_uuids" in warehouse_config:
|
||||
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
|
||||
|
||||
# ============================================================================
|
||||
# 物料默认参数配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
|
||||
# - 格式: 物料名称 → {参数字典}
|
||||
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
|
||||
# - unit: 物料的计量单位(会用于 unit 字段)
|
||||
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
|
||||
|
||||
MATERIAL_DEFAULT_PARAMETERS = {
|
||||
# 溶剂类
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
|
||||
},
|
||||
# 可以继续添加其他物料...
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 物料类型默认参数配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 为特定物料类型(UUID)自动添加默认参数
|
||||
# - 格式: Bioyond类型UUID → {参数字典}
|
||||
# - 优先级低于按名称匹配的配置
|
||||
MATERIAL_TYPE_PARAMETERS = {
|
||||
# 示例:
|
||||
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
|
||||
# "unit": "个"
|
||||
# }
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing_extensions import TypedDict
|
||||
import requests
|
||||
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
||||
import pint
|
||||
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||
@@ -25,13 +26,89 @@ class ComputeExperimentDesignReturn(TypedDict):
|
||||
class BioyondDispensingStation(BioyondWorkstation):
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
# 桌子
|
||||
deck,
|
||||
*args,
|
||||
config: dict = None,
|
||||
deck=None,
|
||||
protocol_type=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(config, deck, *args, **kwargs)
|
||||
):
|
||||
"""初始化配液站
|
||||
|
||||
Args:
|
||||
config: 配置字典,应包含material_type_mappings等配置
|
||||
deck: Deck对象
|
||||
protocol_type: 协议类型(由ROS系统传递,此处忽略)
|
||||
**kwargs: 其他可能的参数
|
||||
"""
|
||||
if config is None:
|
||||
config = {}
|
||||
|
||||
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
|
||||
config.update(kwargs)
|
||||
|
||||
if deck is None and config:
|
||||
deck = config.get('deck')
|
||||
|
||||
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
|
||||
# 从配置中读取 warehouse_mapping,并应用到实际的 deck 资源上
|
||||
if config and "warehouse_mapping" in config and deck:
|
||||
warehouse_mapping = config["warehouse_mapping"]
|
||||
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
|
||||
|
||||
user_deck = deck
|
||||
# 初始化 warehouses 字典
|
||||
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
|
||||
user_deck.warehouses = {}
|
||||
|
||||
# 1. 尝试从 children 中查找匹配的资源
|
||||
for child in user_deck.children:
|
||||
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
|
||||
if child.name in warehouse_mapping:
|
||||
user_deck.warehouses[child.name] = child
|
||||
print(f" - 从子资源中找到 warehouse: {child.name}")
|
||||
|
||||
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
|
||||
if not user_deck.warehouses and hasattr(user_deck, "setup"):
|
||||
print(" - 尝试调用 deck.setup() 初始化仓库...")
|
||||
try:
|
||||
user_deck.setup()
|
||||
# setup 后重新检查
|
||||
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
|
||||
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
|
||||
except Exception as e:
|
||||
print(f" - 调用 setup() 失败: {e}")
|
||||
|
||||
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
|
||||
if not user_deck.warehouses:
|
||||
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
|
||||
|
||||
for wh_name, wh_config in warehouse_mapping.items():
|
||||
target_uuid = wh_config.get("uuid")
|
||||
|
||||
# 尝试在 deck.warehouses 中查找
|
||||
wh_resource = None
|
||||
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
|
||||
wh_resource = user_deck.warehouses[wh_name]
|
||||
|
||||
# 如果没找到,尝试在所有子资源中查找
|
||||
if not wh_resource:
|
||||
wh_resource = user_deck.get_resource(wh_name)
|
||||
|
||||
if wh_resource:
|
||||
if target_uuid:
|
||||
current_uuid = getattr(wh_resource, "uuid", None)
|
||||
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
|
||||
|
||||
# 动态添加 uuid 属性
|
||||
wh_resource.uuid = target_uuid
|
||||
# 同时也确保 category 正确,避免 graphio 识别错误
|
||||
# wh_resource.category = "warehouse"
|
||||
else:
|
||||
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
|
||||
else:
|
||||
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
|
||||
|
||||
super().__init__(bioyond_config=config, deck=deck)
|
||||
|
||||
# self.config = config
|
||||
# self.api_key = config["api_key"]
|
||||
# self.host = config["api_host"]
|
||||
@@ -43,6 +120,41 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||
self.order_completion_status = {}
|
||||
|
||||
# 初始化 pint 单位注册表
|
||||
self.ureg = pint.UnitRegistry()
|
||||
|
||||
# 化合物信息
|
||||
self.compound_info = {
|
||||
"MolWt": {
|
||||
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
||||
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
||||
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
||||
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
||||
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
||||
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
||||
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
||||
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
||||
},
|
||||
"FuncGroup": {
|
||||
"MDA": "Amine",
|
||||
"TDA": "Amine",
|
||||
"PAPP": "Amine",
|
||||
"BTDA": "Anhydride",
|
||||
"BPDA": "Anhydride",
|
||||
"6FAP": "Amine",
|
||||
"MPDA": "Amine",
|
||||
"SIDA": "Amine",
|
||||
"PMDA": "Anhydride",
|
||||
"ODA": "Amine",
|
||||
"4,4'-ODA": "Amine",
|
||||
"134": "Amine",
|
||||
}
|
||||
}
|
||||
|
||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||
"""项目接口通用POST调用
|
||||
|
||||
@@ -54,7 +166,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||
"""
|
||||
request_data = {
|
||||
"apiKey": API_CONFIG["api_key"],
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||
"data": data
|
||||
}
|
||||
@@ -85,7 +197,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||
"""
|
||||
request_data = {
|
||||
"apiKey": API_CONFIG["api_key"],
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||
"data": data
|
||||
}
|
||||
@@ -118,20 +230,22 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
ratio = json.loads(ratio)
|
||||
except Exception:
|
||||
ratio = {}
|
||||
root = str(Path(__file__).resolve().parents[3])
|
||||
if root not in sys.path:
|
||||
sys.path.append(root)
|
||||
try:
|
||||
mod = importlib.import_module("tem.compute")
|
||||
except Exception as e:
|
||||
raise BioyondException(f"无法导入计算模块: {e}")
|
||||
try:
|
||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||
except Exception as e:
|
||||
raise BioyondException(f"参数解析失败: {e}")
|
||||
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
||||
|
||||
# 2. 调用内部计算方法
|
||||
res = self._generate_experiment_design(
|
||||
ratio=ratio,
|
||||
wt_percent=wp,
|
||||
m_tot=mt,
|
||||
titration_percent=tp
|
||||
)
|
||||
|
||||
# 3. 构造返回结果
|
||||
out = {
|
||||
"solutions": res.get("solutions", []),
|
||||
"titration": res.get("titration", {}),
|
||||
@@ -140,11 +254,248 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
"return_info": json.dumps(res, ensure_ascii=False)
|
||||
}
|
||||
return out
|
||||
|
||||
except BioyondException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BioyondException(str(e))
|
||||
|
||||
def _generate_experiment_design(
|
||||
self,
|
||||
ratio: dict,
|
||||
wt_percent: float = 0.25,
|
||||
m_tot: float = 70,
|
||||
titration_percent: float = 0.03,
|
||||
) -> dict:
|
||||
"""内部方法:生成实验设计
|
||||
|
||||
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
||||
|
||||
参数:
|
||||
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
||||
wt_percent: 固体重量百分比
|
||||
m_tot: 反应混合物总质量(g)
|
||||
titration_percent: 滴定溶液百分比
|
||||
|
||||
返回:
|
||||
包含实验设计详细参数的字典
|
||||
"""
|
||||
# 溶剂密度
|
||||
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
||||
# 二酐溶解度
|
||||
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
||||
# 投入固体时最小溶剂体积
|
||||
V_min = 30 * self.ureg.ml
|
||||
m_tot = m_tot * self.ureg.g
|
||||
|
||||
# 保持ratio中的顺序
|
||||
compound_names = list(ratio.keys())
|
||||
compound_ratios = list(ratio.values())
|
||||
|
||||
# 验证所有化合物是否在 compound_info 中定义
|
||||
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
||||
if undefined_compounds:
|
||||
available = list(self.compound_info["MolWt"].keys())
|
||||
raise ValueError(
|
||||
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
||||
f"可用的化合物: {available}"
|
||||
)
|
||||
|
||||
# 获取各化合物的分子量和官能团类型
|
||||
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
||||
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
||||
|
||||
# 记录化合物信息用于调试
|
||||
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
||||
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
||||
|
||||
# 按原始顺序分离二胺和二酐
|
||||
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
||||
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
||||
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
||||
|
||||
if not diamine_compounds or not anhydride_compounds:
|
||||
raise ValueError(
|
||||
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
||||
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
||||
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
||||
)
|
||||
|
||||
# 计算加权平均分子量 (基于摩尔比)
|
||||
total_molar_ratio = sum(compound_ratios)
|
||||
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
||||
|
||||
# 取最后一个二酐用于滴定
|
||||
titration_anhydride = anhydride_compounds[-1]
|
||||
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
||||
|
||||
# 二胺溶液配制参数 - 每种二胺单独配制
|
||||
diamine_solutions = []
|
||||
total_diamine_volume = 0 * self.ureg.ml
|
||||
|
||||
# 计算反应物的总摩尔量
|
||||
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
||||
|
||||
for name, ratio_val, mw, order_index in diamine_compounds:
|
||||
# 跳过 SIDA
|
||||
if name == "SIDA":
|
||||
continue
|
||||
|
||||
# 计算该二胺需要的摩尔数
|
||||
n_diamine_needed = n_reactant * ratio_val
|
||||
|
||||
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
||||
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
||||
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
||||
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
||||
|
||||
# 计算该二胺溶液的浓度
|
||||
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
||||
|
||||
# 计算需要移取的溶液体积
|
||||
V_diamine_needed = n_diamine_needed / c_diamine
|
||||
|
||||
diamine_solutions.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"solid_mass": m_diamine_solid.magnitude,
|
||||
"solvent_volume": V_solvent_for_this.magnitude,
|
||||
"concentration": c_diamine.magnitude,
|
||||
"volume_needed": V_diamine_needed.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
total_diamine_volume += V_diamine_needed
|
||||
|
||||
# 按原始顺序排序
|
||||
diamine_solutions.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算滴定二酐的质量
|
||||
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
||||
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
||||
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
||||
m_titration_10 = m_titration_anhydride * titration_percent
|
||||
|
||||
# 计算其他固体二酐的质量 (按顺序)
|
||||
solid_anhydride_masses = []
|
||||
for name, ratio_val, mw, order_index in solid_anhydrides:
|
||||
mass = n_reactant * ratio_val * mw
|
||||
solid_anhydride_masses.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"mass": mass.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
# 按原始顺序排序
|
||||
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算溶剂用量
|
||||
total_diamine_solution_mass = sum(
|
||||
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
||||
) * self.ureg.ml
|
||||
|
||||
# 预估滴定溶剂量、计算补加溶剂量
|
||||
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
||||
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
||||
|
||||
# 检查最小溶剂体积要求
|
||||
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
||||
m_tot_min = V_min / total_liquid_volume * m_tot
|
||||
|
||||
# 如果需要,按比例放大
|
||||
scale_factor = 1.0
|
||||
if m_tot_min > m_tot:
|
||||
scale_factor = (m_tot_min / m_tot).magnitude
|
||||
m_titration_90 *= scale_factor
|
||||
m_titration_10 *= scale_factor
|
||||
m_solvent_add *= scale_factor
|
||||
m_solvent_titration *= scale_factor
|
||||
|
||||
# 更新二胺溶液用量
|
||||
for sol in diamine_solutions:
|
||||
sol["volume_needed"] *= scale_factor
|
||||
|
||||
# 更新固体二酐用量
|
||||
for anhydride in solid_anhydride_masses:
|
||||
anhydride["mass"] *= scale_factor
|
||||
|
||||
m_tot = m_tot_min
|
||||
|
||||
# 生成投料顺序
|
||||
feeding_order = []
|
||||
|
||||
# 1. 固体二酐 (按顺序)
|
||||
for anhydride in solid_anhydride_masses:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "solid_anhydride",
|
||||
"name": anhydride["name"],
|
||||
"amount": anhydride["mass"],
|
||||
"order": anhydride["order"]
|
||||
})
|
||||
|
||||
# 2. 二胺溶液 (按顺序)
|
||||
for sol in diamine_solutions:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "diamine_solution",
|
||||
"name": sol["name"],
|
||||
"amount": sol["volume_needed"],
|
||||
"order": sol["order"]
|
||||
})
|
||||
|
||||
# 3. 主要二酐粉末
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "main_anhydride",
|
||||
"name": titration_name,
|
||||
"amount": m_titration_90.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 4. 补加溶剂
|
||||
if m_solvent_add > 0:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "additional_solvent",
|
||||
"name": "溶剂",
|
||||
"amount": m_solvent_add.magnitude,
|
||||
"order": 999
|
||||
})
|
||||
|
||||
# 5. 滴定二酐溶液
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "titration_anhydride",
|
||||
"name": f"{titration_name} 滴定液",
|
||||
"amount": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 返回实验设计结果
|
||||
results = {
|
||||
"total_mass": m_tot.magnitude,
|
||||
"scale_factor": scale_factor,
|
||||
"solutions": diamine_solutions,
|
||||
"solids": solid_anhydride_masses,
|
||||
"titration": {
|
||||
"name": titration_name,
|
||||
"main_portion": m_titration_90.magnitude,
|
||||
"titration_portion": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
},
|
||||
"solvents": {
|
||||
"additional_solvent": m_solvent_add.magnitude,
|
||||
"total_liquid_volume": total_liquid_volume.magnitude
|
||||
},
|
||||
"feeding_order": feeding_order,
|
||||
"minimum_required_mass": m_tot_min.magnitude
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
# 90%10%小瓶投料任务创建方法
|
||||
def create_90_10_vial_feeding_task(self,
|
||||
order_name: str = None,
|
||||
@@ -961,6 +1312,108 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
'actualVolume': actual_volume
|
||||
}
|
||||
|
||||
def _simplify_report(self, report) -> Dict[str, Any]:
|
||||
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
||||
if not isinstance(report, dict):
|
||||
return report
|
||||
|
||||
data = report.get('data', {})
|
||||
if not isinstance(data, dict):
|
||||
return report
|
||||
|
||||
# 提取关键信息
|
||||
simplified = {
|
||||
'name': data.get('name'),
|
||||
'code': data.get('code'),
|
||||
'requester': data.get('requester'),
|
||||
'workflowName': data.get('workflowName'),
|
||||
'workflowStep': data.get('workflowStep'),
|
||||
'requestTime': data.get('requestTime'),
|
||||
'startPreparationTime': data.get('startPreparationTime'),
|
||||
'completeTime': data.get('completeTime'),
|
||||
'useTime': data.get('useTime'),
|
||||
'status': data.get('status'),
|
||||
'statusName': data.get('statusName'),
|
||||
}
|
||||
|
||||
# 提取物料信息(简化版)
|
||||
pre_intakes = data.get('preIntakes', [])
|
||||
if pre_intakes and isinstance(pre_intakes, list):
|
||||
first_intake = pre_intakes[0]
|
||||
sample_materials = first_intake.get('sampleMaterials', [])
|
||||
|
||||
# 简化物料信息
|
||||
simplified_materials = []
|
||||
for material in sample_materials:
|
||||
if isinstance(material, dict):
|
||||
mat_info = {
|
||||
'materialName': material.get('materialName'),
|
||||
'materialTypeName': material.get('materialTypeName'),
|
||||
'materialCode': material.get('materialCode'),
|
||||
'materialLocation': material.get('materialLocation'),
|
||||
}
|
||||
|
||||
# 解析parameters中的关键信息(如密度、加料历史等)
|
||||
params_str = material.get('parameters', '{}')
|
||||
try:
|
||||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||
if isinstance(params, dict):
|
||||
# 只保留关键参数
|
||||
if 'density' in params:
|
||||
mat_info['density'] = params['density']
|
||||
if 'feedingHistory' in params:
|
||||
mat_info['feedingHistory'] = params['feedingHistory']
|
||||
if 'liquidVolume' in params:
|
||||
mat_info['liquidVolume'] = params['liquidVolume']
|
||||
if 'm_diamine_tot' in params:
|
||||
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
||||
if 'wt_diamine' in params:
|
||||
mat_info['wt_diamine'] = params['wt_diamine']
|
||||
except:
|
||||
pass
|
||||
|
||||
simplified_materials.append(mat_info)
|
||||
|
||||
simplified['sampleMaterials'] = simplified_materials
|
||||
|
||||
# 提取extraProperties中的实际值
|
||||
extra_props = first_intake.get('extraProperties', {})
|
||||
if isinstance(extra_props, dict):
|
||||
simplified_extra = {}
|
||||
for key, value in extra_props.items():
|
||||
try:
|
||||
parsed_value = json.loads(value) if isinstance(value, str) else value
|
||||
simplified_extra[key] = parsed_value
|
||||
except:
|
||||
simplified_extra[key] = value
|
||||
simplified['extraProperties'] = simplified_extra
|
||||
|
||||
return {
|
||||
'data': simplified,
|
||||
'code': report.get('code'),
|
||||
'message': report.get('message'),
|
||||
'timestamp': report.get('timestamp')
|
||||
}
|
||||
|
||||
def scheduler_start(self) -> dict:
|
||||
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||
|
||||
Returns:
|
||||
dict: 包含return_info的字典,return_info为整型(1=成功)
|
||||
|
||||
Raises:
|
||||
BioyondException: 调度器启动失败时抛出异常
|
||||
"""
|
||||
result = self.hardware_interface.scheduler_start()
|
||||
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
||||
|
||||
if result != 1:
|
||||
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
||||
self.hardware_interface._logger.error(error_msg)
|
||||
raise BioyondException(error_msg)
|
||||
|
||||
return {"return_info": result}
|
||||
|
||||
# 等待多个任务完成并获取实验报告
|
||||
def wait_for_multiple_orders_and_get_reports(self,
|
||||
batch_create_result: str = None,
|
||||
@@ -1002,7 +1455,12 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证batch_create_result参数
|
||||
if not batch_create_result or batch_create_result == "":
|
||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
||||
raise BioyondException(
|
||||
"batch_create_result参数为空,请确保:\n"
|
||||
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
||||
"2. batch_create节点成功执行并返回了结果\n"
|
||||
"3. 检查上游batch_create任务是否成功创建了订单"
|
||||
)
|
||||
|
||||
# 解析batch_create_result JSON对象
|
||||
try:
|
||||
@@ -1031,7 +1489,17 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证提取的数据
|
||||
if not order_codes:
|
||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
||||
self.hardware_interface._logger.error(
|
||||
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
||||
)
|
||||
raise BioyondException(
|
||||
"batch_create_result中未找到order_codes或为空。\n"
|
||||
"可能的原因:\n"
|
||||
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
||||
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
||||
"3. Bioyond系统状态异常\n"
|
||||
f"请检查batch_create任务的执行结果"
|
||||
)
|
||||
if not order_ids:
|
||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||
|
||||
@@ -1114,6 +1582,8 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
self.hardware_interface._logger.info(
|
||||
f"成功获取任务 {order_code} 的实验报告"
|
||||
)
|
||||
# 简化报告,去除冗余信息
|
||||
report = self._simplify_report(report)
|
||||
|
||||
reports.append({
|
||||
"order_code": order_code,
|
||||
@@ -1288,7 +1758,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||
)
|
||||
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
results = []
|
||||
successful_count = 0
|
||||
failed_count = 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ Bioyond Workstation Implementation
|
||||
"""
|
||||
import time
|
||||
import traceback
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
@@ -23,12 +24,94 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||
)
|
||||
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class ConnectionMonitor:
|
||||
"""Bioyond连接监控器"""
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._last_status = "unknown"
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
||||
self._thread.start()
|
||||
logger.info("Bioyond连接监控器已启动")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
logger.info("Bioyond连接监控器已停止")
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 使用 lightweight API 检查连接
|
||||
# query_matial_type_list 是比较快的查询
|
||||
start_time = time.time()
|
||||
result = self.workstation.hardware_interface.material_type_list()
|
||||
|
||||
status = "online" if result else "offline"
|
||||
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||
|
||||
if status != self._last_status:
|
||||
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||
self._publish_event(status, msg)
|
||||
self._last_status = status
|
||||
|
||||
# 发布心跳 (可选,或者只在状态变更时发布)
|
||||
# self._publish_event(status, msg)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond连接检查异常: {e}")
|
||||
if self._last_status != "error":
|
||||
self._publish_event("error", str(e))
|
||||
self._last_status = "error"
|
||||
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _publish_event(self, status, message):
|
||||
try:
|
||||
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
||||
event_data = {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
||||
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
||||
# 话题: /<namespace>/events/device_status
|
||||
ns = self.workstation._ros_node.namespace
|
||||
topic = f"{ns}/events/device_status"
|
||||
|
||||
# 使用 ROS2DeviceNode 的发布功能
|
||||
# 如果没有预定义的 publisher,需要动态创建
|
||||
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
||||
# 这里我们先尝试用 String json 发布
|
||||
|
||||
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
||||
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
||||
if not hasattr(self.workstation, "_device_status_pub"):
|
||||
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self.workstation._device_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布设备状态事件失败: {e}")
|
||||
|
||||
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
"""Bioyond资源同步器
|
||||
|
||||
@@ -174,9 +257,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
else:
|
||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||
|
||||
# 第1步:获取仓库配置
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = WAREHOUSE_MAPPING
|
||||
# 第1步:从配置中获取仓库配置
|
||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
# 确定目标仓库名称
|
||||
parent_name = None
|
||||
@@ -238,14 +320,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
# 第2步:转换为 Bioyond 格式
|
||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||
|
||||
# 导入物料默认参数配置
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
# 从配置中获取物料默认参数
|
||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = material_default_params.copy()
|
||||
for type_id, params in material_type_params.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
material_params=merged_params
|
||||
)[0]
|
||||
|
||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||
@@ -468,13 +556,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
return material_bioyond_id
|
||||
|
||||
# 转换为 Bioyond 格式
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
# 从配置中获取物料默认参数
|
||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = material_default_params.copy()
|
||||
for type_id, params in material_type_params.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
material_params=merged_params
|
||||
)[0]
|
||||
|
||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||
@@ -528,8 +623,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||
|
||||
# 获取仓库配置和目标库位 UUID
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = WAREHOUSE_MAPPING
|
||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
parent_name = None
|
||||
target_location_uuid = None
|
||||
@@ -584,6 +678,44 @@ class BioyondWorkstation(WorkstationBase):
|
||||
集成Bioyond物料管理的工作站实现
|
||||
"""
|
||||
|
||||
def _publish_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
task_type: str,
|
||||
status: str,
|
||||
result: dict = None,
|
||||
progress: float = 0.0,
|
||||
task_code: str = None
|
||||
):
|
||||
"""发布任务状态事件"""
|
||||
try:
|
||||
if not getattr(self, "_ros_node", None):
|
||||
return
|
||||
|
||||
event_data = {
|
||||
"task_id": task_id,
|
||||
"task_code": task_code,
|
||||
"task_type": task_type,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
if result:
|
||||
event_data["result"] = result
|
||||
|
||||
topic = f"{self._ros_node.namespace}/events/task_status"
|
||||
|
||||
if not hasattr(self, "_task_status_pub"):
|
||||
self._task_status_pub = self._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self._task_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布任务状态事件失败: {e}")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
@@ -605,10 +737,28 @@ class BioyondWorkstation(WorkstationBase):
|
||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||
|
||||
# 初始化 warehouses 属性
|
||||
self.deck.warehouses = {}
|
||||
for resource in self.deck.children:
|
||||
if isinstance(resource, WareHouse):
|
||||
self.deck.warehouses[resource.name] = resource
|
||||
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
||||
self.deck.warehouses = {}
|
||||
|
||||
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
||||
if not self.deck.warehouses:
|
||||
for resource in self.deck.children:
|
||||
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
||||
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
|
||||
|
||||
# 如果配置中有定义,也可以认定为 warehouse
|
||||
if not is_warehouse and "warehouse_mapping" in bioyond_config:
|
||||
if resource.name in bioyond_config["warehouse_mapping"]:
|
||||
is_warehouse = True
|
||||
|
||||
if is_warehouse:
|
||||
self.deck.warehouses[resource.name] = resource
|
||||
# 确保 category 被正确设置,方便后续使用
|
||||
if getattr(resource, "category", "") != "warehouse":
|
||||
try:
|
||||
resource.category = "warehouse"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 创建通信模块
|
||||
self._create_communication_module(bioyond_config)
|
||||
@@ -627,18 +777,22 @@ class BioyondWorkstation(WorkstationBase):
|
||||
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
||||
|
||||
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
||||
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
|
||||
# 从 bioyond_config 中的 http_service_config 获取
|
||||
http_service_cfg = bioyond_config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
||||
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_service_cfg.get("http_service_port", 8080)
|
||||
}
|
||||
self.http_service = None # 将在 post_init 中启动
|
||||
self.http_service = None # 将在 post_init 启动
|
||||
self.connection_monitor = None # 将在 post_init 启动
|
||||
|
||||
logger.info(f"Bioyond工作站初始化完成")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||
try:
|
||||
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
||||
self.connection_monitor.stop()
|
||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||
logger.info("正在停止 HTTP 报送服务...")
|
||||
self.http_service.stop()
|
||||
@@ -648,8 +802,19 @@ class BioyondWorkstation(WorkstationBase):
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# 启动连接监控
|
||||
try:
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
except Exception as e:
|
||||
logger.error(f"启动连接监控失败: {e}")
|
||||
|
||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||
if hasattr(self, '_http_service_config'):
|
||||
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
|
||||
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||
logger.info("🔧 检测到 _disable_auto_http_service 标志,跳过自动启动 HTTP 服务")
|
||||
logger.info(" 子类(BioyondCellWorkstation)已自行管理 HTTP 服务")
|
||||
elif hasattr(self, '_http_service_config'):
|
||||
try:
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
@@ -688,19 +853,14 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""创建Bioyond通信模块"""
|
||||
# 创建默认配置
|
||||
default_config = {
|
||||
**API_CONFIG,
|
||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||
}
|
||||
|
||||
# 如果传入了 config,合并配置(config 中的值会覆盖默认值)
|
||||
# 直接使用传入的配置,不再使用默认值
|
||||
# 所有配置必须从 JSON 文件中提供
|
||||
if config:
|
||||
self.bioyond_config = {**default_config, **config}
|
||||
self.bioyond_config = config
|
||||
else:
|
||||
self.bioyond_config = default_config
|
||||
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
|
||||
self.bioyond_config = {}
|
||||
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
|
||||
@@ -1014,7 +1174,15 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
workflow_id = self._get_workflow(actual_workflow_name)
|
||||
if workflow_id:
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
||||
if isinstance(self.workflow_sequence, list):
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
||||
self._cached_workflow_sequence.append(workflow_id)
|
||||
else:
|
||||
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
||||
return False
|
||||
|
||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||
return True
|
||||
return False
|
||||
@@ -1215,6 +1383,22 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||
# 例如:更新数据库、触发后续流程等
|
||||
|
||||
# 发布任务状态事件 (running/progress update)
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_step",
|
||||
status="running",
|
||||
progress=0.5, # 步骤完成视为任务进行中
|
||||
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
||||
logger.info(f"[步骤完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"step_id": data.get('stepId'),
|
||||
@@ -1249,6 +1433,17 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_sample",
|
||||
status="running",
|
||||
progress=0.7,
|
||||
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"sample_id": data.get('sampleId'),
|
||||
@@ -1288,6 +1483,32 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||
# 例如:更新物料库存、生成报表等
|
||||
|
||||
# 映射状态到事件状态
|
||||
event_status = "completed"
|
||||
if str(data.get('status')) in ["-11", "-12"]:
|
||||
event_status = "error"
|
||||
elif str(data.get('status')) == "30":
|
||||
event_status = "completed"
|
||||
else:
|
||||
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_order",
|
||||
status=event_status,
|
||||
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
||||
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
||||
if event_status == "completed":
|
||||
logger.info(f"[任务完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"order_code": data.get('orderCode'),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Modbus CSV 地址映射说明
|
||||
|
||||
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
|
||||
|
||||
## 1. CSV 文件结构
|
||||
|
||||
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
|
||||
|
||||
每一行定义了一个 Modbus 节点,包含以下关键列:
|
||||
|
||||
| 列名 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
|
||||
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
|
||||
| **Comment** | 注释说明 | `使用铝箔垫` |
|
||||
| **Attribute** | 属性 (通常留空或用于额外标记) | |
|
||||
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
|
||||
| **Address** | **Modbus 地址** (十进制) | `8340` |
|
||||
|
||||
### 示例行 (铝箔垫片)
|
||||
|
||||
```csv
|
||||
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
|
||||
```
|
||||
|
||||
- **名称**: `COIL_ALUMINUM_FOIL`
|
||||
- **类型**: `coil` (线圈,读写单个位)
|
||||
- **地址**: `8340`
|
||||
|
||||
---
|
||||
|
||||
## 2. 加载与注册流程
|
||||
|
||||
在 `coin_cell_assembly.py` 的初始化代码中:
|
||||
|
||||
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
|
||||
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
|
||||
|
||||
```python
|
||||
# 代码位置: coin_cell_assembly.py (L174-175)
|
||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
|
||||
self.client = modbus_client.register_node_list(self.nodes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码中的使用方式
|
||||
|
||||
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
|
||||
|
||||
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
|
||||
|
||||
```python
|
||||
# 代码位置: qiming_coin_cell_code 函数 (L1048)
|
||||
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
|
||||
```
|
||||
|
||||
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
|
||||
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
|
||||
|
||||
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 地址转换注意事项 (Modbus vs PLC)
|
||||
|
||||
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**。
|
||||
|
||||
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...` 或 `%MW...`)。这两者之间通常需要转换。
|
||||
|
||||
### 常见的转换规则 (示例)
|
||||
|
||||
- **Coil (线圈) %QX**:
|
||||
- `Modbus地址 = 字节地址 * 8 + 位偏移`
|
||||
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
|
||||
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
|
||||
|
||||
- **Register (寄存器) %MW**:
|
||||
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
|
||||
|
||||
### 验证方法
|
||||
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`。
|
||||
|
||||
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。
|
||||
@@ -0,0 +1,352 @@
|
||||
# 2026-01-13 物料搜寻确认弹窗自动处理功能
|
||||
|
||||
## 概述
|
||||
|
||||
本次更新为设备初始化流程添加了**物料搜寻确认弹窗自动检测与处理功能**。在设备初始化过程中,PLC 会弹出物料搜寻确认对话框,现在系统可以根据用户参数自动点击"是"或"否"按钮,无需手动干预。
|
||||
|
||||
## 背景问题
|
||||
|
||||
### 原有流程
|
||||
1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备
|
||||
2. PLC 在初始化过程中弹出物料搜寻确认对话框
|
||||
3. **需要人工手动点击**"是"或"否"按钮
|
||||
4. PLC 继续完成初始化并启动
|
||||
|
||||
### 存在的问题
|
||||
- 需要人工干预,无法实现全自动化
|
||||
- 影响批量生产效率
|
||||
- 容易遗忘点击导致流程卡住
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 新增 Modbus 地址配置
|
||||
|
||||
在 `coin_cell_assembly_b.csv` 第 69-71 行添加三个 coil:
|
||||
|
||||
| Name | DeviceType | Address | 说明 |
|
||||
|------|-----------|---------|------|
|
||||
| COIL_MATERIAL_SEARCH_DIALOG_APPEAR | coil | 6470 | 物料搜寻确认弹窗画面是否出现 |
|
||||
| COIL_MATERIAL_SEARCH_CONFIRM_YES | coil | 6480 | 初始化物料搜寻确认按钮"是" |
|
||||
| COIL_MATERIAL_SEARCH_CONFIRM_NO | coil | 6490 | 初始化物料搜寻确认按钮"否" |
|
||||
|
||||
**Modbus 地址转换:**
|
||||
- CSV 6470 → Modbus 5176 (弹窗出现)
|
||||
- CSV 6480 → Modbus 5184 (按钮"是")
|
||||
- CSV 6490 → Modbus 5192 (按钮"否")
|
||||
|
||||
## 代码修改详情
|
||||
|
||||
### 1. coin_cell_assembly.py
|
||||
|
||||
#### 1.1 新增辅助方法 `_handle_material_search_dialog()`
|
||||
|
||||
**位置:** 第 799-901 行
|
||||
|
||||
**功能:**
|
||||
- 监测物料搜寻确认弹窗是否出现(Coil 5176)
|
||||
- 根据 `enable_search` 参数自动点击对应按钮
|
||||
- 使用**脉冲模式**模拟真实按钮操作:`True` → 保持 0.5 秒 → `False`
|
||||
|
||||
**参数:**
|
||||
- `enable_search: bool` - True=点击"是"(启用物料搜寻), False=点击"否"(不启用)
|
||||
- `timeout: int = 30` - 等待弹窗出现的最大时间(秒)
|
||||
|
||||
**逻辑流程:**
|
||||
```python
|
||||
1. 监测 COIL_MATERIAL_SEARCH_DIALOG_APPEAR (每 0.5 秒检查一次)
|
||||
2. 检测到弹窗出现 (Coil = True)
|
||||
3. 选择按钮:
|
||||
- enable_search=True → COIL_MATERIAL_SEARCH_CONFIRM_YES
|
||||
- enable_search=False → COIL_MATERIAL_SEARCH_CONFIRM_NO
|
||||
4. 执行脉冲操作:
|
||||
- 写入 True (按下按钮)
|
||||
- 等待 0.5 秒
|
||||
- 写入 False (释放按钮)
|
||||
- 验证状态
|
||||
```
|
||||
|
||||
#### 1.2 修改 `func_pack_device_init_auto_start_combined()`
|
||||
|
||||
**位置:** 第 904-1115 行
|
||||
|
||||
**主要改动:**
|
||||
|
||||
1. **添加新参数**
|
||||
```python
|
||||
def func_pack_device_init_auto_start_combined(
|
||||
self,
|
||||
material_search_enable: bool = False # 新增参数
|
||||
) -> bool:
|
||||
```
|
||||
|
||||
2. **内联初始化逻辑并集成弹窗检测**
|
||||
- 不再调用 `self.func_pack_device_init()`
|
||||
- 将初始化逻辑直接实现在函数内
|
||||
- **在等待初始化完成的循环中实时检测弹窗**
|
||||
- 避免死锁:PLC 等待弹窗确认 ↔ 代码等待初始化完成
|
||||
|
||||
3. **关键代码片段**
|
||||
```python
|
||||
# 等待初始化完成,同时检测物料搜寻弹窗
|
||||
while (self._sys_init_status()) == False:
|
||||
# 检查超时
|
||||
if time.time() - start_wait > max_wait_time:
|
||||
raise RuntimeError(f"初始化超时")
|
||||
|
||||
# 如果还没处理弹窗,检测弹窗是否出现
|
||||
if not dialog_handled:
|
||||
dialog_state = self.client.use_node('COIL_MATERIAL_SEARCH_DIALOG_APPEAR').read(1)
|
||||
if dialog_actual: # 弹窗出现
|
||||
# 执行脉冲按钮点击
|
||||
button_node.write(True) # 按下
|
||||
time.sleep(0.5) # 保持
|
||||
button_node.write(False) # 释放
|
||||
dialog_handled = True
|
||||
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
4. **步骤调整**
|
||||
- 步骤 0: 前置条件检查
|
||||
- 步骤 1: 设备初始化(**包含弹窗检测**)
|
||||
- 步骤 1.5: 已在步骤 1 中完成
|
||||
- 步骤 2: 切换自动模式
|
||||
- 步骤 3: 启动设备
|
||||
|
||||
### 2. coin_cell_workstation.yaml
|
||||
|
||||
**位置:** 第 292-312 行
|
||||
|
||||
**修改内容:**
|
||||
|
||||
```yaml
|
||||
auto-func_pack_device_init_auto_start_combined:
|
||||
goal_default:
|
||||
material_search_enable: false # 新增默认值
|
||||
|
||||
schema:
|
||||
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||
|
||||
goal:
|
||||
properties:
|
||||
material_search_enable: # 新增参数配置
|
||||
default: false
|
||||
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||
type: boolean
|
||||
```
|
||||
|
||||
### 3. 测试脚本(已创建,用户已删除)
|
||||
|
||||
#### 3.1 test_material_search_dialog.py
|
||||
- 从 CSV 动态加载 Modbus 地址
|
||||
- 支持 4 种测试模式:
|
||||
- `query` - 查询所有状态
|
||||
- `dialog <0|1>` - 设置弹窗出现/消失
|
||||
- `yes` - 脉冲点击"是"按钮
|
||||
- `no` - 脉冲点击"否"按钮
|
||||
- 兼容 pymodbus 3.x API
|
||||
|
||||
#### 3.2 更新其他测试脚本
|
||||
- `test_coin_cell_reset.py` - 更新为 pymodbus 3.x API
|
||||
- `test_unilab_interact.py` - 更新为 pymodbus 3.x API
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `material_search_enable` | boolean | `false` | 是否启用物料搜寻功能 |
|
||||
|
||||
### 调用示例
|
||||
|
||||
#### 1. 不启用物料搜寻(默认)
|
||||
```python
|
||||
# 默认参数,点击"否"按钮
|
||||
await device.func_pack_device_init_auto_start_combined()
|
||||
```
|
||||
|
||||
或在 YAML workflow 中:
|
||||
```yaml
|
||||
# 使用默认值 false,不启用物料搜寻
|
||||
- BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||
```
|
||||
|
||||
#### 2. 启用物料搜寻
|
||||
```python
|
||||
# 显式设置为 True,点击"是"按钮
|
||||
await device.func_pack_device_init_auto_start_combined(
|
||||
material_search_enable=True
|
||||
)
|
||||
```
|
||||
|
||||
或在 YAML workflow 中:
|
||||
```yaml
|
||||
- BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||
goal:
|
||||
material_search_enable: true # 启用物料搜寻
|
||||
```
|
||||
|
||||
## 执行日志示例
|
||||
|
||||
```
|
||||
26-01-13 [21:32:44] [INFO] 开始组合操作:设备初始化 → 物料搜寻确认 → 自动模式 → 启动
|
||||
26-01-13 [21:32:44] [INFO] 【步骤 0/4】前置条件检查...
|
||||
26-01-13 [21:32:44] [INFO] ✓ REG_UNILAB_INTERACT 检查通过
|
||||
26-01-13 [21:32:44] [INFO] ✓ COIL_GB_L_IGNORE_CMD 检查通过
|
||||
26-01-13 [21:32:44] [INFO] 【步骤 1/4】设备初始化...
|
||||
26-01-13 [21:32:44] [INFO] 切换手动模式...
|
||||
26-01-13 [21:32:46] [INFO] 发送初始化命令...
|
||||
26-01-13 [21:32:47] [INFO] 等待初始化完成(同时监测物料搜寻弹窗)...
|
||||
26-01-13 [21:33:05] [INFO] ✓ 在初始化过程中检测到物料搜寻确认弹窗!
|
||||
26-01-13 [21:33:05] [INFO] 用户选择: 不启用物料搜寻(点击否)
|
||||
26-01-13 [21:33:05] [INFO] → 按下按钮 '否'
|
||||
26-01-13 [21:33:06] [INFO] → 释放按钮 '否'
|
||||
26-01-13 [21:33:07] [INFO] ✓ 成功处理物料搜寻确认弹窗(选择: 否)
|
||||
26-01-13 [21:33:08] [INFO] ✓ 初始化状态完成
|
||||
26-01-13 [21:33:12] [INFO] ✓ 设备初始化完成
|
||||
26-01-13 [21:33:12] [INFO] 【步骤 1.5/4】物料搜寻确认已在初始化过程中完成
|
||||
26-01-13 [21:33:12] [INFO] 【步骤 2/4】切换自动模式...
|
||||
26-01-13 [21:33:15] [INFO] ✓ 切换自动模式完成
|
||||
26-01-13 [21:33:15] [INFO] 【步骤 3/4】启动设备...
|
||||
26-01-13 [21:33:18] [INFO] ✓ 启动设备完成
|
||||
26-01-13 [21:33:18] [INFO] 组合操作完成:设备已成功初始化、确认物料搜寻、切换自动模式并启动
|
||||
```
|
||||
|
||||
## 技术要点
|
||||
|
||||
### 1. 脉冲模式按钮操作
|
||||
模拟真实按钮按压过程:
|
||||
1. 写入 `True` (按下)
|
||||
2. 保持 0.5 秒
|
||||
3. 写入 `False` (释放)
|
||||
4. 验证状态
|
||||
|
||||
### 2. 避免死锁
|
||||
**问题:** PLC 在初始化过程中等待弹窗确认,而代码等待初始化完成
|
||||
**解决:** 在初始化等待循环中实时检测弹窗,一旦出现立即处理
|
||||
|
||||
### 3. 超时保护
|
||||
- 弹窗检测超时:30 秒(在 `_handle_material_search_dialog` 中)
|
||||
- 初始化超时:120 秒(在 `func_pack_device_init_auto_start_combined` 中)
|
||||
|
||||
### 4. PyModbus 3.x API 兼容
|
||||
所有 Modbus 操作使用 keyword arguments:
|
||||
```python
|
||||
# 读取
|
||||
client.read_coils(address=5176, count=1)
|
||||
|
||||
# 写入
|
||||
client.write_coil(address=5184, value=True)
|
||||
```
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
### 保留的原有函数
|
||||
- `func_pack_device_init()` - 单独的初始化函数,不包含弹窗处理
|
||||
- 仍可在 YAML 中通过 `auto-func_pack_device_init` 调用
|
||||
- 用于不需要自动处理弹窗的场景
|
||||
|
||||
### 新增的功能
|
||||
- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理
|
||||
- 通过参数控制,默认行为与之前兼容(点击"否")
|
||||
|
||||
## 验证测试
|
||||
|
||||
### 测试场景
|
||||
|
||||
#### 场景 1:默认参数(不启用物料搜寻)
|
||||
```bash
|
||||
# 调用时不传参数
|
||||
BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||
```
|
||||
**预期结果:**
|
||||
- ✅ 检测到弹窗
|
||||
- ✅ 自动点击"否"按钮
|
||||
- ✅ 初始化完成并启动成功
|
||||
|
||||
#### 场景 2:启用物料搜寻
|
||||
```bash
|
||||
# 设置 material_search_enable=true
|
||||
BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||
goal:
|
||||
material_search_enable: true
|
||||
```
|
||||
**预期结果:**
|
||||
- ✅ 检测到弹窗
|
||||
- ✅ 自动点击"是"按钮
|
||||
- ✅ 初始化完成并启动成功
|
||||
|
||||
### 实际测试结果
|
||||
|
||||
**测试时间:** 2026-01-13 21:32:43
|
||||
**测试参数:** `material_search_enable: false`
|
||||
**测试结果:** ✅ 成功
|
||||
|
||||
**关键时间节点:**
|
||||
- 21:33:05 - 检测到弹窗
|
||||
- 21:33:05 - 按下"否"按钮
|
||||
- 21:33:06 - 释放"否"按钮
|
||||
- 21:33:07 - 弹窗处理完成
|
||||
- 21:33:08 - 初始化状态完成
|
||||
- 21:33:18 - 整个流程完成
|
||||
|
||||
**总耗时:** 约 35 秒(包含初始化全过程)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **CSV 配置依赖**
|
||||
- 确保 `coin_cell_assembly_b.csv` 包含 69-71 行的 coil 配置
|
||||
- 地址转换逻辑:`modbus_addr = (csv_addr // 10) * 8 + (csv_addr % 10)`
|
||||
|
||||
2. **默认行为**
|
||||
- 默认 `material_search_enable=false`,即不启用物料搜寻
|
||||
- 如需启用,必须显式设置为 `true`
|
||||
|
||||
3. **日志级别**
|
||||
- 弹窗检测过程中的 `waiting for init_cmd` 使用 DEBUG 级别
|
||||
- 关键操作(检测到弹窗、按钮操作)使用 INFO 级别
|
||||
|
||||
4. **原有函数保留**
|
||||
- `func_pack_device_init()` 仍然可用,但不包含弹窗处理
|
||||
- 如果单独调用此函数,仍需手动处理弹窗
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 修改的文件
|
||||
1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`
|
||||
- 新增 `_handle_material_search_dialog()` 方法
|
||||
- 修改 `func_pack_device_init_auto_start_combined()` 函数
|
||||
|
||||
2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml`
|
||||
- 更新 `auto-func_pack_device_init_auto_start_combined` 配置
|
||||
- 添加 `material_search_enable` 参数说明
|
||||
|
||||
3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv`
|
||||
- 第 69-71 行添加三个 coil 配置
|
||||
|
||||
### 创建的测试文件(已删除)
|
||||
1. `test_material_search_dialog.py` - 物料搜寻弹窗测试脚本
|
||||
2. `test_coin_cell_reset.py` - 复位功能测试(更新为 pymodbus 3.x)
|
||||
3. `test_unilab_interact.py` - Unilab 交互测试(更新为 pymodbus 3.x)
|
||||
|
||||
## 总结
|
||||
|
||||
本次更新成功实现了设备初始化过程中物料搜寻确认弹窗的自动化处理,主要优势:
|
||||
|
||||
✅ **全自动化** - 无需人工干预
|
||||
✅ **参数可配** - 灵活控制是否启用物料搜寻
|
||||
✅ **实时检测** - 在初始化等待循环中检测,避免死锁
|
||||
✅ **脉冲模式** - 模拟真实按钮操作
|
||||
✅ **向后兼容** - 保留原有函数,不影响现有流程
|
||||
✅ **完整日志** - 详细记录每一步操作
|
||||
✅ **超时保护** - 防止无限等待
|
||||
|
||||
该功能已通过实际测试验证,可投入生产使用。
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** 1.0
|
||||
**创建日期:** 2026-01-13
|
||||
**作者:** Antigravity AI Assistant
|
||||
**最后更新:** 2026-01-13 21:36
|
||||
@@ -0,0 +1,645 @@
|
||||
"""
|
||||
纽扣电池组装工作站物料类定义
|
||||
Button Battery Assembly Station Resource Classes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
|
||||
|
||||
from pylabrobot.resources.coordinate import Coordinate
|
||||
from pylabrobot.resources.container import Container
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from pylabrobot.resources.itemized_resource import ItemizedResource
|
||||
from pylabrobot.resources.resource import Resource
|
||||
from pylabrobot.resources.resource_stack import ResourceStack
|
||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||
from pylabrobot.resources.trash import Trash
|
||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
|
||||
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
|
||||
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
|
||||
|
||||
|
||||
|
||||
# TODO: 这个应该只能放一个极片
|
||||
class MaterialHoleState(TypedDict):
|
||||
diameter: int
|
||||
depth: int
|
||||
max_sheets: int
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class MaterialHole(Resource):
|
||||
"""料板洞位类"""
|
||||
children: List[ElectrodeSheet] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "material_hole",
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: MaterialHoleState = MaterialHoleState(
|
||||
diameter=20,
|
||||
depth=10,
|
||||
max_sheets=1,
|
||||
info=None
|
||||
)
|
||||
|
||||
def get_all_sheet_info(self):
|
||||
info_list = []
|
||||
for sheet in self.children:
|
||||
info_list.append(sheet._unilabos_state["info"])
|
||||
return info_list
|
||||
|
||||
#这个函数函数好像没用,一般不会集中赋值质量
|
||||
def set_all_sheet_mass(self):
|
||||
for sheet in self.children:
|
||||
sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
#移动极片前先取出对象
|
||||
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
|
||||
for sheet in self.children:
|
||||
if sheet.name == name:
|
||||
return sheet
|
||||
return None
|
||||
|
||||
def has_electrode_sheet(self) -> bool:
|
||||
"""检查洞位是否有极片"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: ElectrodeSheet,
|
||||
location: Optional[Coordinate],
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题
|
||||
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||
#if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||
# raise ValueError(f"洞位已满,无法放置更多极片")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
|
||||
return self.children[index]
|
||||
|
||||
|
||||
class MaterialPlateState(TypedDict):
|
||||
hole_spacing_x: float
|
||||
hole_spacing_y: float
|
||||
hole_diameter: float
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
"""料板类 - 4x4个洞位,每个洞位放1个极片"""
|
||||
|
||||
children: List[MaterialHole]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
ordered_items: Optional[Dict[str, MaterialHole]] = None,
|
||||
ordering: Optional[OrderedDict[str, str]] = None,
|
||||
category: str = "material_plate",
|
||||
model: Optional[str] = None,
|
||||
fill: bool = False
|
||||
):
|
||||
"""初始化料板
|
||||
|
||||
Args:
|
||||
name: 料板名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
hole_spacing_x: X方向洞位间距 (mm)
|
||||
hole_spacing_y: Y方向洞位间距 (mm)
|
||||
number: 编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
self._unilabos_state: MaterialPlateState = MaterialPlateState(
|
||||
hole_spacing_x=24.0,
|
||||
hole_spacing_y=24.0,
|
||||
hole_diameter=20.0,
|
||||
info="",
|
||||
)
|
||||
# 创建4x4的洞位
|
||||
# TODO: 这里要改,对应不同形状
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=size_z,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 16,
|
||||
size_y = 16,
|
||||
size_z = 16,
|
||||
)
|
||||
if fill:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=holes,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
else:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def update_locations(self):
|
||||
# TODO:调多次相加
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=self._size_z,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 1,
|
||||
size_y = 1,
|
||||
size_z = 1,
|
||||
)
|
||||
for item, original_item in zip(holes.items(), self.children):
|
||||
original_item.location = item[1].location
|
||||
|
||||
|
||||
class PlateSlot(ResourceStack):
|
||||
"""板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
max_plates: int = 8,
|
||||
category: str = "plate_slot",
|
||||
model: Optional[str] = None
|
||||
):
|
||||
"""初始化板槽位
|
||||
|
||||
Args:
|
||||
name: 槽位名称
|
||||
max_plates: 最大板数量
|
||||
category: 类别
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
direction="z", # Z方向堆叠
|
||||
resources=[],
|
||||
)
|
||||
self.max_plates = max_plates
|
||||
self.category = category
|
||||
|
||||
def can_add_plate(self) -> bool:
|
||||
"""检查是否可以添加板"""
|
||||
return len(self.children) < self.max_plates
|
||||
|
||||
def add_plate(self, plate: MaterialPlate) -> None:
|
||||
"""添加料板"""
|
||||
if not self.can_add_plate():
|
||||
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
|
||||
self.assign_child_resource(plate)
|
||||
|
||||
def get_top_plate(self) -> MaterialPlate:
|
||||
"""获取最上方的板"""
|
||||
if len(self.children) == 0:
|
||||
raise ValueError(f"槽位 {self.name} 为空")
|
||||
return cast(MaterialPlate, self.get_top_item())
|
||||
|
||||
def take_top_plate(self) -> MaterialPlate:
|
||||
"""取出最上方的板"""
|
||||
top_plate = self.get_top_plate()
|
||||
self.unassign_child_resource(top_plate)
|
||||
return top_plate
|
||||
|
||||
def can_access_for_picking(self) -> bool:
|
||||
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
"max_plates": self.max_plates,
|
||||
}
|
||||
|
||||
|
||||
#是一种类型注解,不用self
|
||||
class BatteryState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float
|
||||
height: float
|
||||
assembly_pressure: float
|
||||
electrolyte_volume: float
|
||||
electrolyte_name: str
|
||||
|
||||
class Battery(Resource):
|
||||
"""电池类 - 可容纳极片"""
|
||||
children: List[ElectrodeSheet] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category: str = "battery",
|
||||
):
|
||||
"""初始化电池
|
||||
|
||||
Args:
|
||||
name: 电池名称
|
||||
diameter: 直径 (mm)
|
||||
height: 高度 (mm)
|
||||
max_volume: 最大容量 (μL)
|
||||
barcode: 二维码编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryState = BatteryState(
|
||||
diameter = 1.0,
|
||||
height = 1.0,
|
||||
assembly_pressure = 1.0,
|
||||
electrolyte_volume = 1.0,
|
||||
electrolyte_name = "DP001"
|
||||
)
|
||||
|
||||
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
|
||||
to_add_name = bottle._unilabos_state["electrolyte_name"]
|
||||
if bottle.aspirate_electrolyte(10):
|
||||
if self.add_electrolyte(to_add_name, 10):
|
||||
pass
|
||||
else:
|
||||
bottle._unilabos_state["electrolyte_volume"] += 10
|
||||
|
||||
def set_electrolyte(self, name: str, volume: float) -> None:
|
||||
"""设置电解液信息"""
|
||||
self._unilabos_state["electrolyte_name"] = name
|
||||
self._unilabos_state["electrolyte_volume"] = volume
|
||||
#这个应该没用,不会有加了后再加的事情
|
||||
def add_electrolyte(self, name: str, volume: float) -> bool:
|
||||
"""添加电解液信息"""
|
||||
if name != self._unilabos_state["electrolyte_name"]:
|
||||
return False
|
||||
self._unilabos_state["electrolyte_volume"] += volume
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
# 电解液作为属性放进去
|
||||
|
||||
class BatteryPressSlotState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float =20.0
|
||||
depth: float = 4.0
|
||||
|
||||
class BatteryPressSlot(Resource):
|
||||
"""电池压制槽类 - 设备,可容纳一个电池"""
|
||||
children: List[Battery] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "BatteryPressSlot",
|
||||
category: str = "battery_press_slot",
|
||||
):
|
||||
"""初始化电池压制槽
|
||||
|
||||
Args:
|
||||
name: 压制槽名称
|
||||
diameter: 直径 (mm)
|
||||
depth: 深度 (mm)
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=10,
|
||||
size_y=12,
|
||||
size_z=13,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
|
||||
|
||||
def has_battery(self) -> bool:
|
||||
"""检查是否有电池"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: Battery,
|
||||
location: Optional[Coordinate],
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 让高京看下槽位只有一个电池时是否这么写。
|
||||
if self.has_battery():
|
||||
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
def get_battery_info(self, index: int) -> Battery:
|
||||
return self.children[0]
|
||||
|
||||
|
||||
def TipBox64(
|
||||
name: str,
|
||||
size_x: float = 127.8,
|
||||
size_y: float = 85.5,
|
||||
size_z: float = 60.0,
|
||||
category: str = "tip_rack",
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""64孔枪头盒类"""
|
||||
from pylabrobot.resources.tip import Tip
|
||||
|
||||
# 创建12x8=96个枪头位
|
||||
def make_tip():
|
||||
return Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=20.0,
|
||||
maximal_volume=1000, # 1mL
|
||||
fitting_depth=8.0,
|
||||
)
|
||||
|
||||
tip_spots = create_ordered_items_2d(
|
||||
klass=TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=8.0,
|
||||
dy=8.0,
|
||||
dz=0.0,
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
size_x=10,
|
||||
size_y=10,
|
||||
size_z=0.0,
|
||||
make_tip=make_tip,
|
||||
)
|
||||
idx_available = list(range(0, 32)) + list(range(64, 96))
|
||||
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
|
||||
tip_rack = TipRack(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
# ordered_items=tip_spots_available,
|
||||
ordered_items=tip_spots,
|
||||
category=category,
|
||||
model=model,
|
||||
with_tips=False,
|
||||
)
|
||||
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头
|
||||
return tip_rack
|
||||
|
||||
|
||||
class WasteTipBoxstate(TypedDict):
|
||||
""""废枪头盒状态字典"""
|
||||
max_tips: int = 100
|
||||
tip_count: int = 0
|
||||
|
||||
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
|
||||
class WasteTipBox(Trash):
|
||||
"""废枪头盒类 - 100个枪头容量"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 127.8,
|
||||
size_y: float = 85.5,
|
||||
size_z: float = 60.0,
|
||||
material_z_thickness=0,
|
||||
max_volume=float("inf"),
|
||||
category="trash",
|
||||
model=None,
|
||||
compute_volume_from_height=None,
|
||||
compute_height_from_volume=None,
|
||||
):
|
||||
"""初始化废枪头盒
|
||||
|
||||
Args:
|
||||
name: 废枪头盒名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
max_tips: 最大枪头容量
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
|
||||
|
||||
def add_tip(self) -> None:
|
||||
"""添加废枪头"""
|
||||
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
|
||||
raise ValueError(f"废枪头盒 {self.name} 已满")
|
||||
self._unilabos_state["tip_count"] += 1
|
||||
|
||||
def get_tip_count(self) -> int:
|
||||
"""获取枪头数量"""
|
||||
return self._unilabos_state["tip_count"]
|
||||
|
||||
def empty(self) -> None:
|
||||
"""清空废枪头盒"""
|
||||
self._unilabos_state["tip_count"] = 0
|
||||
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
|
||||
class CoincellDeck(Deck):
|
||||
"""纽扣电池组装工作站台面类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "coin_cell_deck",
|
||||
size_x: float = 1450.0, # 1m
|
||||
size_y: float = 1450.0, # 1m
|
||||
size_z: float = 100.0, # 0.9m
|
||||
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||
category: str = "coin_cell_deck",
|
||||
setup: bool = False, # 是否自动执行 setup
|
||||
):
|
||||
"""初始化纽扣电池组装工作站台面
|
||||
|
||||
Args:
|
||||
name: 台面名称
|
||||
size_x: 长度 (mm) - 1m
|
||||
size_y: 宽度 (mm) - 1m
|
||||
size_z: 高度 (mm) - 0.9m
|
||||
origin: 原点坐标
|
||||
category: 类别
|
||||
setup: 是否自动执行 setup 配置标准布局
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1450.0,
|
||||
size_y=1450.0,
|
||||
size_z=100.0,
|
||||
origin=origin,
|
||||
)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
|
||||
# ====================================== 子弹夹 ============================================
|
||||
|
||||
# 正极片(4个洞位,2x2布局)
|
||||
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
|
||||
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
|
||||
|
||||
# 正极壳、平垫片(6个洞位,2x2+2布局)
|
||||
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
|
||||
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
|
||||
|
||||
# 负极壳、弹垫片(6个洞位,2x2+2布局)
|
||||
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
|
||||
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
|
||||
|
||||
# 成品弹夹(6个洞位,3x2布局)
|
||||
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
|
||||
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
|
||||
|
||||
# ====================================== 物料板 ============================================
|
||||
# 创建物料板(料盘carrier)- 4x4布局
|
||||
# 负极料盘
|
||||
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||
# for i in range(16):
|
||||
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||
|
||||
# 隔膜料盘
|
||||
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||
# for i in range(16):
|
||||
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
|
||||
|
||||
# ====================================== 瓶架、移液枪 ============================================
|
||||
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
|
||||
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
|
||||
|
||||
# bottle_rack_3x4 = BottleRack(
|
||||
# name="bottle_rack_3x4",
|
||||
# size_x=210.0,
|
||||
# size_y=140.0,
|
||||
# size_z=100.0,
|
||||
# num_items_x=2,
|
||||
# num_items_y=4,
|
||||
# position_spacing=35.0,
|
||||
# orientation="vertical",
|
||||
# )
|
||||
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
|
||||
|
||||
# 电解液缓存位 - 6x2布局
|
||||
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
|
||||
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
|
||||
# 电解液回收位6x2
|
||||
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
|
||||
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
|
||||
|
||||
tip_box = TipBox64(name="tip_box_64")
|
||||
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
|
||||
|
||||
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||
|
||||
|
||||
def YH_Deck(name=""):
|
||||
cd = CoincellDeck(name=name)
|
||||
cd.setup()
|
||||
return cd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
deck = create_coin_cell_deck()
|
||||
print(deck)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
||||
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
||||
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
||||
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
||||
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
||||
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
||||
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
||||
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
||||
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
||||
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
||||
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
||||
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
||||
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
||||
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
||||
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
||||
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
||||
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
||||
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
||||
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
|
||||
,,,,,coil,8320,
|
||||
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
|
||||
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
|
||||
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
|
||||
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
|
||||
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
|
||||
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
|
||||
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
|
||||
,BOOL,,,,coil,8300,
|
||||
,BOOL,,,,coil,8310,
|
||||
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
|
||||
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
|
||||
,BOOL,,,,coil,8350,
|
||||
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
|
||||
,BOOL,,,,coil,8380,
|
||||
,BOOL,,,,coil,8390,
|
||||
,BOOL,,,,coil,8400,
|
||||
,BOOL,,,,coil,8410,
|
||||
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
|
||||
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
|
||||
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
|
||||
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
|
||||
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
||||
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
||||
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
||||
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
|
||||
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
|
||||
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
|
||||
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
|
||||
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
|
||||
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
|
||||
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
|
||||
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
|
||||
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
|
||||
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
|
||||
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
|
||||
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
|
||||
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
|
||||
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
|
||||
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
|
||||
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
|
||||
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
|
||||
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
|
||||
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
|
||||
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
|
||||
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
|
||||
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
|
||||
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
|
||||
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
|
||||
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
|
||||
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
|
||||
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
|
||||
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
|
||||
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
|
||||
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
|
||||
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
|
||||
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
|
||||
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
|
||||
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制
|
||||
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
|
||||
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败
|
||||
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败
|
||||
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败
|
||||
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败
|
||||
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
|
||||
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
|
||||
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
|
||||
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
|
||||
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
|
||||
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
|
||||
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
|
||||
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
|
||||
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
|
||||
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
|
||||
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
|
||||
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
|
||||
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
|
||||
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
|
||||
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
|
||||
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
|
||||
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
|
||||
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
|
||||
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
|
||||
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
|
||||
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
|
||||
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
|
||||
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
# 验证必需字段
|
||||
if 'brand' in request_data:
|
||||
if request_data['brand'] == "bioyond": # 奔曜
|
||||
error_msg = request_data["text"]
|
||||
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
||||
material_data = request_data["text"]
|
||||
logger.info(f"收到奔曜物料变更报送: {material_data}")
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已收到: {error_msg}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
||||
message=f"物料变更报送已收到: {material_data}",
|
||||
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
|
||||
data=None
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -1,589 +0,0 @@
|
||||
workstation.bioyond_dispensing_station:
|
||||
category:
|
||||
- workstation
|
||||
- bioyond
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-batch_create_90_10_vial_feeding_tasks:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
delay_time: null
|
||||
hold_m_name: null
|
||||
liquid_material_name: NMP
|
||||
speed: null
|
||||
temperature: null
|
||||
titration: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
hold_m_name:
|
||||
type: string
|
||||
liquid_material_name:
|
||||
default: NMP
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
titration:
|
||||
type: string
|
||||
required:
|
||||
- titration
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: batch_create_90_10_vial_feeding_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-batch_create_diamine_solution_tasks:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
delay_time: null
|
||||
liquid_material_name: NMP
|
||||
solutions: null
|
||||
speed: null
|
||||
temperature: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
liquid_material_name:
|
||||
default: NMP
|
||||
type: string
|
||||
solutions:
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
required:
|
||||
- solutions
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: batch_create_diamine_solution_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-brief_step_parameters:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
data: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: brief_step_parameters参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-compute_experiment_design:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: null
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
type: string
|
||||
ratio:
|
||||
type: object
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
items: {}
|
||||
title: Feeding Order
|
||||
type: array
|
||||
return_info:
|
||||
title: Return Info
|
||||
type: string
|
||||
solutions:
|
||||
items: {}
|
||||
title: Solutions
|
||||
type: array
|
||||
solvents:
|
||||
additionalProperties: true
|
||||
title: Solvents
|
||||
type: object
|
||||
titration:
|
||||
additionalProperties: true
|
||||
title: Titration
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
- titration
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesignReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: compute_experiment_design参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_order_finish_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
report_request: null
|
||||
used_materials: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
type: string
|
||||
used_materials:
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
- used_materials
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_order_finish_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-project_order_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
order_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: project_order_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-query_resource_by_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_name:
|
||||
type: string
|
||||
required:
|
||||
- material_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: query_resource_by_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
type: string
|
||||
transfer_groups:
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
batch_create_result: null
|
||||
check_interval: 10
|
||||
timeout: 7200
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
batch_create_result:
|
||||
type: string
|
||||
check_interval:
|
||||
default: 10
|
||||
type: integer
|
||||
timeout:
|
||||
default: 7200
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sample_locations:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_id:
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sample_locations参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
create_90_10_vial_feeding_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
delay_time: delay_time
|
||||
hold_m_name: hold_m_name
|
||||
order_name: order_name
|
||||
percent_10_1_assign_material_name: percent_10_1_assign_material_name
|
||||
percent_10_1_liquid_material_name: percent_10_1_liquid_material_name
|
||||
percent_10_1_target_weigh: percent_10_1_target_weigh
|
||||
percent_10_1_volume: percent_10_1_volume
|
||||
percent_10_2_assign_material_name: percent_10_2_assign_material_name
|
||||
percent_10_2_liquid_material_name: percent_10_2_liquid_material_name
|
||||
percent_10_2_target_weigh: percent_10_2_target_weigh
|
||||
percent_10_2_volume: percent_10_2_volume
|
||||
percent_10_3_assign_material_name: percent_10_3_assign_material_name
|
||||
percent_10_3_liquid_material_name: percent_10_3_liquid_material_name
|
||||
percent_10_3_target_weigh: percent_10_3_target_weigh
|
||||
percent_10_3_volume: percent_10_3_volume
|
||||
percent_90_1_assign_material_name: percent_90_1_assign_material_name
|
||||
percent_90_1_target_weigh: percent_90_1_target_weigh
|
||||
percent_90_2_assign_material_name: percent_90_2_assign_material_name
|
||||
percent_90_2_target_weigh: percent_90_2_target_weigh
|
||||
percent_90_3_assign_material_name: percent_90_3_assign_material_name
|
||||
percent_90_3_target_weigh: percent_90_3_target_weigh
|
||||
speed: speed
|
||||
temperature: temperature
|
||||
goal_default:
|
||||
delay_time: ''
|
||||
hold_m_name: ''
|
||||
order_name: ''
|
||||
percent_10_1_assign_material_name: ''
|
||||
percent_10_1_liquid_material_name: ''
|
||||
percent_10_1_target_weigh: ''
|
||||
percent_10_1_volume: ''
|
||||
percent_10_2_assign_material_name: ''
|
||||
percent_10_2_liquid_material_name: ''
|
||||
percent_10_2_target_weigh: ''
|
||||
percent_10_2_volume: ''
|
||||
percent_10_3_assign_material_name: ''
|
||||
percent_10_3_liquid_material_name: ''
|
||||
percent_10_3_target_weigh: ''
|
||||
percent_10_3_volume: ''
|
||||
percent_90_1_assign_material_name: ''
|
||||
percent_90_1_target_weigh: ''
|
||||
percent_90_2_assign_material_name: ''
|
||||
percent_90_2_target_weigh: ''
|
||||
percent_90_3_assign_material_name: ''
|
||||
percent_90_3_target_weigh: ''
|
||||
speed: ''
|
||||
temperature: ''
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: DispenStationVialFeed_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
hold_m_name:
|
||||
type: string
|
||||
order_name:
|
||||
type: string
|
||||
percent_10_1_assign_material_name:
|
||||
type: string
|
||||
percent_10_1_liquid_material_name:
|
||||
type: string
|
||||
percent_10_1_target_weigh:
|
||||
type: string
|
||||
percent_10_1_volume:
|
||||
type: string
|
||||
percent_10_2_assign_material_name:
|
||||
type: string
|
||||
percent_10_2_liquid_material_name:
|
||||
type: string
|
||||
percent_10_2_target_weigh:
|
||||
type: string
|
||||
percent_10_2_volume:
|
||||
type: string
|
||||
percent_10_3_assign_material_name:
|
||||
type: string
|
||||
percent_10_3_liquid_material_name:
|
||||
type: string
|
||||
percent_10_3_target_weigh:
|
||||
type: string
|
||||
percent_10_3_volume:
|
||||
type: string
|
||||
percent_90_1_assign_material_name:
|
||||
type: string
|
||||
percent_90_1_target_weigh:
|
||||
type: string
|
||||
percent_90_2_assign_material_name:
|
||||
type: string
|
||||
percent_90_2_target_weigh:
|
||||
type: string
|
||||
percent_90_3_assign_material_name:
|
||||
type: string
|
||||
percent_90_3_target_weigh:
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- percent_90_1_assign_material_name
|
||||
- percent_90_1_target_weigh
|
||||
- percent_90_2_assign_material_name
|
||||
- percent_90_2_target_weigh
|
||||
- percent_90_3_assign_material_name
|
||||
- percent_90_3_target_weigh
|
||||
- percent_10_1_assign_material_name
|
||||
- percent_10_1_target_weigh
|
||||
- percent_10_1_volume
|
||||
- percent_10_1_liquid_material_name
|
||||
- percent_10_2_assign_material_name
|
||||
- percent_10_2_target_weigh
|
||||
- percent_10_2_volume
|
||||
- percent_10_2_liquid_material_name
|
||||
- percent_10_3_assign_material_name
|
||||
- percent_10_3_target_weigh
|
||||
- percent_10_3_volume
|
||||
- percent_10_3_liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationVialFeed_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationVialFeed_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: DispenStationVialFeed
|
||||
type: object
|
||||
type: DispenStationVialFeed
|
||||
create_diamine_solution_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
delay_time: delay_time
|
||||
hold_m_name: hold_m_name
|
||||
liquid_material_name: liquid_material_name
|
||||
material_name: material_name
|
||||
order_name: order_name
|
||||
speed: speed
|
||||
target_weigh: target_weigh
|
||||
temperature: temperature
|
||||
volume: volume
|
||||
goal_default:
|
||||
delay_time: ''
|
||||
hold_m_name: ''
|
||||
liquid_material_name: ''
|
||||
material_name: ''
|
||||
order_name: ''
|
||||
speed: ''
|
||||
target_weigh: ''
|
||||
temperature: ''
|
||||
volume: ''
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: DispenStationSolnPrep_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
hold_m_name:
|
||||
type: string
|
||||
liquid_material_name:
|
||||
type: string
|
||||
material_name:
|
||||
type: string
|
||||
order_name:
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
target_weigh:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
volume:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- material_name
|
||||
- target_weigh
|
||||
- volume
|
||||
- liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationSolnPrep_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationSolnPrep_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: DispenStationSolnPrep
|
||||
type: object
|
||||
type: DispenStationSolnPrep
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
description: ''
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
config:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
required:
|
||||
- config
|
||||
- deck
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
version: 1.0.0
|
||||
2144
unilabos/registry/devices/bioyond_cell.yaml
Normal file
2144
unilabos/registry/devices/bioyond_cell.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,71 +30,6 @@ bioyond_dispensing_station:
|
||||
title: brief_step_parameters参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-compute_experiment_design:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: null
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
type: string
|
||||
ratio:
|
||||
type: object
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
items: {}
|
||||
title: Feeding Order
|
||||
type: array
|
||||
return_info:
|
||||
title: Return Info
|
||||
type: string
|
||||
solutions:
|
||||
items: {}
|
||||
title: Solutions
|
||||
type: array
|
||||
solvents:
|
||||
additionalProperties: true
|
||||
title: Solvents
|
||||
type: object
|
||||
titration:
|
||||
additionalProperties: true
|
||||
title: Titration
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
- titration
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesignReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: compute_experiment_design参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_order_finish_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -174,35 +109,6 @@ bioyond_dispensing_station:
|
||||
title: query_resource_by_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
type: string
|
||||
transfer_groups:
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sample_locations:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -394,6 +300,99 @@ bioyond_dispensing_station:
|
||||
title: BatchCreateDiamineSolutionTasks
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
compute_experiment_design:
|
||||
feedback: {}
|
||||
goal:
|
||||
m_tot: m_tot
|
||||
ratio: ratio
|
||||
titration_percent: titration_percent
|
||||
wt_percent: wt_percent
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: ''
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles:
|
||||
output:
|
||||
- data_key: solutions
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: solutions
|
||||
io_type: sink
|
||||
label: Solution Data From Python
|
||||
- data_key: titration
|
||||
data_source: executor
|
||||
data_type: object
|
||||
handler_key: titration
|
||||
io_type: sink
|
||||
label: Titration Data From Calculation Node
|
||||
- data_key: solvents
|
||||
data_source: executor
|
||||
data_type: object
|
||||
handler_key: solvents
|
||||
io_type: sink
|
||||
label: Solvents Data From Calculation Node
|
||||
- data_key: feeding_order
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: feeding_order
|
||||
io_type: sink
|
||||
label: Feeding Order Data From Calculation Node
|
||||
result:
|
||||
feeding_order: feeding_order
|
||||
return_info: return_info
|
||||
solutions: solutions
|
||||
solvents: solvents
|
||||
titration: titration
|
||||
schema:
|
||||
description: 计算实验设计,输出solutions/titration/solvents/feeding_order用于后续节点。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
description: 总质量(g)
|
||||
type: string
|
||||
ratio:
|
||||
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
|
||||
type: string
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
description: 滴定比例(10%部分)
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
description: 目标固含质量分数
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
type: array
|
||||
return_info:
|
||||
type: string
|
||||
solutions:
|
||||
type: array
|
||||
solvents:
|
||||
type: object
|
||||
titration:
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
- titration
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesign_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: ComputeExperimentDesign
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
create_90_10_vial_feeding_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -620,6 +619,89 @@ bioyond_dispensing_station:
|
||||
title: DispenStationSolnPrep
|
||||
type: object
|
||||
type: DispenStationSolnPrep
|
||||
scheduler_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond配液站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 调度器启动结果,成功返回1,失败返回0
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal:
|
||||
target_device_id: target_device_id
|
||||
transfer_groups: transfer_groups
|
||||
goal_default:
|
||||
target_device_id: ''
|
||||
transfer_groups: ''
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
target_device_id: unilabos_devices
|
||||
result: {}
|
||||
schema:
|
||||
description: 将配液站完成的物料(溶液、样品等)转移到指定反应站的堆栈库位。支持配置多组转移任务,每组包含物料名称、目标堆栈和目标库位。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备)
|
||||
type: string
|
||||
transfer_groups:
|
||||
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
|
||||
items:
|
||||
properties:
|
||||
materials:
|
||||
description: 物料名称(手动输入,系统将通过RPC查询验证)
|
||||
type: string
|
||||
target_sites:
|
||||
description: 目标库位(手动输入,如"A01")
|
||||
type: string
|
||||
target_stack:
|
||||
description: 目标堆栈名称(从列表选择)
|
||||
enum:
|
||||
- 堆栈1左
|
||||
- 堆栈1右
|
||||
- 站内试剂存放堆栈
|
||||
type: string
|
||||
required:
|
||||
- materials
|
||||
- target_stack
|
||||
- target_sites
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -688,7 +770,7 @@ bioyond_dispensing_station:
|
||||
title: WaitForMultipleOrdersAndGetReports
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station.dispensing_station:BioyondDispensingStation
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
@@ -699,15 +781,16 @@ bioyond_dispensing_station:
|
||||
config:
|
||||
properties:
|
||||
config:
|
||||
type: string
|
||||
type: object
|
||||
deck:
|
||||
type: string
|
||||
required:
|
||||
- config
|
||||
- deck
|
||||
protocol_type:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
model: {}
|
||||
version: 1.0.0
|
||||
|
||||
@@ -317,6 +317,47 @@ separator.chinwe:
|
||||
- port
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
separation_step:
|
||||
goal:
|
||||
max_cycles: 0
|
||||
motor_id: 5
|
||||
pulses: 700
|
||||
speed: 60
|
||||
timeout: 300
|
||||
handles: {}
|
||||
schema:
|
||||
description: 分液步骤 - 液位传感器与电机联动 (有液→顺时针, 无液→逆时针)
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
max_cycles:
|
||||
default: 0
|
||||
description: 最大循环次数 (0=无限制)
|
||||
type: integer
|
||||
motor_id:
|
||||
default: '5'
|
||||
description: 选择电机
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
title: '注: 4=搅拌, 5=旋钮'
|
||||
type: string
|
||||
pulses:
|
||||
default: 700
|
||||
description: 每次旋转脉冲数 (约1/4圈)
|
||||
type: integer
|
||||
speed:
|
||||
default: 60
|
||||
description: 电机转速 (RPM)
|
||||
type: integer
|
||||
timeout:
|
||||
default: 300
|
||||
description: 超时时间 (秒)
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_sensor_level:
|
||||
goal:
|
||||
target_state: 有液
|
||||
|
||||
850
unilabos/registry/devices/coin_cell_workstation.yaml
Normal file
850
unilabos/registry/devices/coin_cell_workstation.yaml
Normal file
@@ -0,0 +1,850 @@
|
||||
coincellassemblyworkstation_device:
|
||||
category:
|
||||
- coin_cell_workstation
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-change_hole_sheet_to_2:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
hole: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
hole:
|
||||
type: object
|
||||
required:
|
||||
- hole
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: change_hole_sheet_to_2参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-fill_plate:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: fill_plate参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-fun_wuliao_test:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: fun_wuliao_test参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: /Users/sml/work
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
type: integer
|
||||
elec_num:
|
||||
type: string
|
||||
elec_use_num:
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
type: integer
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
type: string
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd_simp:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
battery_clean_ignore: false
|
||||
battery_pressure_mode: true
|
||||
dual_drop_first_volume: 25
|
||||
dual_drop_mode: false
|
||||
dual_drop_start_timing: false
|
||||
dual_drop_suction_timing: false
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: /Users/sml/work
|
||||
fujipian_juzhendianwei: 0
|
||||
fujipian_panshu: 0
|
||||
gemo_juzhendianwei: 0
|
||||
gemopanshu: 0
|
||||
lvbodian: true
|
||||
qiangtou_juzhendianwei: 0
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 简化版电池组装函数,整合了参数设置和双滴模式
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
description: 电池压制力(N)
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫)
|
||||
type: integer
|
||||
battery_clean_ignore:
|
||||
default: false
|
||||
description: 是否忽略电池清洁步骤
|
||||
type: boolean
|
||||
battery_pressure_mode:
|
||||
default: true
|
||||
description: 是否启用压力模式
|
||||
type: boolean
|
||||
dual_drop_first_volume:
|
||||
default: 25
|
||||
description: 二次滴液第一次排液体积(μL)
|
||||
type: integer
|
||||
dual_drop_mode:
|
||||
default: false
|
||||
description: 电解液添加模式(false=单次滴液, true=二次滴液)
|
||||
type: boolean
|
||||
dual_drop_start_timing:
|
||||
default: false
|
||||
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
|
||||
type: boolean
|
||||
dual_drop_suction_timing:
|
||||
default: false
|
||||
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
|
||||
type: boolean
|
||||
elec_num:
|
||||
description: 电解液瓶数
|
||||
type: string
|
||||
elec_use_num:
|
||||
description: 每瓶电解液组装电池数
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
description: 电解液吸液量(μL)
|
||||
type: integer
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
description: 实验记录保存路径
|
||||
type: string
|
||||
fujipian_juzhendianwei:
|
||||
default: 0
|
||||
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
fujipian_panshu:
|
||||
default: 0
|
||||
description: 负极片盘数
|
||||
type: integer
|
||||
gemo_juzhendianwei:
|
||||
default: 0
|
||||
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
gemopanshu:
|
||||
default: 0
|
||||
description: 隔膜盘数
|
||||
type: integer
|
||||
lvbodian:
|
||||
default: true
|
||||
description: 是否使用铝箔垫片
|
||||
type: boolean
|
||||
qiangtou_juzhendianwei:
|
||||
default: 0
|
||||
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd_simp参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_get_csv_export_status:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_get_csv_export_status参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_auto:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_auto参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_init_auto_start_combined:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_search_enable: false
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_search_enable:
|
||||
default: false
|
||||
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init_auto_start_combined参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_stop:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_get_msg_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
file_path: D:\coin_cell_data
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
file_path:
|
||||
default: D:\coin_cell_data
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_get_msg_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_bottle_num:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
bottle_num: null
|
||||
handles:
|
||||
input:
|
||||
- data_key: bottle_num
|
||||
data_source: workflow
|
||||
data_type: integer
|
||||
handler_key: bottle_count
|
||||
io_type: source
|
||||
label: 配液瓶数
|
||||
required: true
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
bottle_num:
|
||||
type: string
|
||||
required:
|
||||
- bottle_num
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_bottle_num参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_finished_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_finished_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_msg_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: null
|
||||
assembly_type: null
|
||||
elec_use_num: null
|
||||
elec_vol: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
type: string
|
||||
assembly_type:
|
||||
type: string
|
||||
elec_use_num:
|
||||
type: string
|
||||
elec_vol:
|
||||
type: string
|
||||
required:
|
||||
- elec_use_num
|
||||
- elec_vol
|
||||
- assembly_type
|
||||
- assembly_pressure
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_msg_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_read_data_and_output:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
file_path: /Users/sml/work
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_read_data_and_output参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_sendbottle_allpack_multi:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
battery_clean_ignore: false
|
||||
battery_pressure_mode: true
|
||||
dual_drop_first_volume: 25
|
||||
dual_drop_mode: false
|
||||
dual_drop_start_timing: false
|
||||
dual_drop_suction_timing: false
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: /Users/sml/work
|
||||
fujipian_juzhendianwei: 0
|
||||
fujipian_panshu: 0
|
||||
gemo_juzhendianwei: 0
|
||||
gemopanshu: 0
|
||||
lvbodian: true
|
||||
qiangtou_juzhendianwei: 0
|
||||
handles:
|
||||
input:
|
||||
- data_key: elec_num
|
||||
data_source: workflow
|
||||
data_type: integer
|
||||
handler_key: bottle_count
|
||||
io_type: source
|
||||
label: 配液瓶数
|
||||
required: true
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 发送瓶数+简化组装函数(适用于第二批次及后续批次),合并了发送瓶数和简化组装流程
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
description: 电池压制力(N)
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫)
|
||||
type: integer
|
||||
battery_clean_ignore:
|
||||
default: false
|
||||
description: 是否忽略电池清洁步骤
|
||||
type: boolean
|
||||
battery_pressure_mode:
|
||||
default: true
|
||||
description: 是否启用压力模式
|
||||
type: boolean
|
||||
dual_drop_first_volume:
|
||||
default: 25
|
||||
description: 二次滴液第一次排液体积(μL)
|
||||
type: integer
|
||||
dual_drop_mode:
|
||||
default: false
|
||||
description: 电解液添加模式(false=单次滴液, true=二次滴液)
|
||||
type: boolean
|
||||
dual_drop_start_timing:
|
||||
default: false
|
||||
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
|
||||
type: boolean
|
||||
dual_drop_suction_timing:
|
||||
default: false
|
||||
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
|
||||
type: boolean
|
||||
elec_num:
|
||||
description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数
|
||||
type: string
|
||||
elec_use_num:
|
||||
description: 每瓶电解液组装电池数
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
description: 电解液吸液量(μL)
|
||||
type: integer
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
description: 实验记录保存路径
|
||||
type: string
|
||||
fujipian_juzhendianwei:
|
||||
default: 0
|
||||
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
fujipian_panshu:
|
||||
default: 0
|
||||
description: 负极片盘数
|
||||
type: integer
|
||||
gemo_juzhendianwei:
|
||||
default: 0
|
||||
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
gemopanshu:
|
||||
default: 0
|
||||
description: 隔膜盘数
|
||||
type: integer
|
||||
lvbodian:
|
||||
default: true
|
||||
description: 是否使用铝箔垫片
|
||||
type: boolean
|
||||
qiangtou_juzhendianwei:
|
||||
default: 0
|
||||
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_sendbottle_allpack_multi参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_stop_read_data:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_stop_read_data参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-modify_deck_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
resource_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
resource_name:
|
||||
type: string
|
||||
required:
|
||||
- resource_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: modify_deck_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: object
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-qiming_coin_cell_code:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
battery_clean_ignore: false
|
||||
battery_pressure: 4000
|
||||
battery_pressure_mode: true
|
||||
fujipian_juzhendianwei: 0
|
||||
fujipian_panshu: null
|
||||
gemo_juzhendianwei: 0
|
||||
gemopanshu: 0
|
||||
lvbodian: true
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
battery_clean_ignore:
|
||||
default: false
|
||||
type: boolean
|
||||
battery_pressure:
|
||||
default: 4000
|
||||
type: integer
|
||||
battery_pressure_mode:
|
||||
default: true
|
||||
type: boolean
|
||||
fujipian_juzhendianwei:
|
||||
default: 0
|
||||
type: integer
|
||||
fujipian_panshu:
|
||||
type: integer
|
||||
gemo_juzhendianwei:
|
||||
default: 0
|
||||
type: integer
|
||||
gemopanshu:
|
||||
default: 0
|
||||
type: integer
|
||||
lvbodian:
|
||||
default: true
|
||||
type: boolean
|
||||
required:
|
||||
- fujipian_panshu
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: qiming_coin_cell_code参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||
status_types:
|
||||
data_assembly_coin_cell_num: int
|
||||
data_assembly_pressure: int
|
||||
data_assembly_time: float
|
||||
data_axis_x_pos: float
|
||||
data_axis_y_pos: float
|
||||
data_axis_z_pos: float
|
||||
data_coin_cell_code: str
|
||||
data_coin_num: int
|
||||
data_electrolyte_code: str
|
||||
data_electrolyte_volume: int
|
||||
data_glove_box_o2_content: float
|
||||
data_glove_box_pressure: float
|
||||
data_glove_box_water_content: float
|
||||
data_open_circuit_voltage: float
|
||||
data_pole_weight: float
|
||||
request_rec_msg_status: bool
|
||||
request_send_msg_status: bool
|
||||
sys_mode: str
|
||||
sys_status: str
|
||||
type: python
|
||||
config_info: []
|
||||
description: ''
|
||||
handles: []
|
||||
icon: koudian.webp
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
address:
|
||||
default: 172.16.28.102
|
||||
type: string
|
||||
config:
|
||||
type: object
|
||||
debug_mode:
|
||||
default: false
|
||||
type: boolean
|
||||
deck:
|
||||
type: string
|
||||
port:
|
||||
default: '502'
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
data_assembly_coin_cell_num:
|
||||
type: integer
|
||||
data_assembly_pressure:
|
||||
type: integer
|
||||
data_assembly_time:
|
||||
type: number
|
||||
data_axis_x_pos:
|
||||
type: number
|
||||
data_axis_y_pos:
|
||||
type: number
|
||||
data_axis_z_pos:
|
||||
type: number
|
||||
data_coin_cell_code:
|
||||
type: string
|
||||
data_coin_num:
|
||||
type: integer
|
||||
data_electrolyte_code:
|
||||
type: string
|
||||
data_electrolyte_volume:
|
||||
type: integer
|
||||
data_glove_box_o2_content:
|
||||
type: number
|
||||
data_glove_box_pressure:
|
||||
type: number
|
||||
data_glove_box_water_content:
|
||||
type: number
|
||||
data_open_circuit_voltage:
|
||||
type: number
|
||||
data_pole_weight:
|
||||
type: number
|
||||
request_rec_msg_status:
|
||||
type: boolean
|
||||
request_send_msg_status:
|
||||
type: boolean
|
||||
sys_mode:
|
||||
type: string
|
||||
sys_status:
|
||||
type: string
|
||||
required:
|
||||
- sys_status
|
||||
- sys_mode
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- data_assembly_coin_cell_num
|
||||
- data_assembly_time
|
||||
- data_open_circuit_voltage
|
||||
- data_axis_x_pos
|
||||
- data_axis_y_pos
|
||||
- data_axis_z_pos
|
||||
- data_pole_weight
|
||||
- data_assembly_pressure
|
||||
- data_electrolyte_volume
|
||||
- data_coin_num
|
||||
- data_coin_cell_code
|
||||
- data_electrolyte_code
|
||||
- data_glove_box_pressure
|
||||
- data_glove_box_o2_content
|
||||
- data_glove_box_water_content
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
@@ -4976,13 +4976,13 @@ liquid_handler.biomek:
|
||||
handler_key: tip_rack
|
||||
label: tip_rack
|
||||
output:
|
||||
- data_key: sources
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: sources
|
||||
- data_key: targets
|
||||
data_source: handle
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: targets
|
||||
@@ -7656,43 +7656,6 @@ liquid_handler.prcxi:
|
||||
title: iter_tips参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-magnetic_action:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
height: null
|
||||
is_wait: null
|
||||
module_no: null
|
||||
time: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
height:
|
||||
type: integer
|
||||
is_wait:
|
||||
type: boolean
|
||||
module_no:
|
||||
type: integer
|
||||
time:
|
||||
type: integer
|
||||
required:
|
||||
- time
|
||||
- module_no
|
||||
- height
|
||||
- is_wait
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: magnetic_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-move_to:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -7846,47 +7809,6 @@ liquid_handler.prcxi:
|
||||
title: shaker_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-shaking_incubation_action:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
amplitude: null
|
||||
is_wait: null
|
||||
module_no: null
|
||||
temperature: null
|
||||
time: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
amplitude:
|
||||
type: integer
|
||||
is_wait:
|
||||
type: boolean
|
||||
module_no:
|
||||
type: integer
|
||||
temperature:
|
||||
type: integer
|
||||
time:
|
||||
type: integer
|
||||
required:
|
||||
- time
|
||||
- module_no
|
||||
- amplitude
|
||||
- is_wait
|
||||
- temperature
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: shaking_incubation_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-touch_tip:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -10237,7 +10159,7 @@ liquid_handler.prcxi:
|
||||
data_type: resource
|
||||
handler_key: targets_identifier
|
||||
label: 转移目标
|
||||
- data_key: tip_racks
|
||||
- data_key: tip_rack
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack_identifier
|
||||
|
||||
286
unilabos/registry/devices/motor.yaml
Normal file
286
unilabos/registry/devices/motor.yaml
Normal file
@@ -0,0 +1,286 @@
|
||||
motor.zdt_x42:
|
||||
category:
|
||||
- motor
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-enable:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
'on': true
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 使能或禁用电机。使能后电机进入锁轴状态,可接收运动指令;禁用后电机进入松轴状态。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
'on':
|
||||
default: true
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-get_position:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 获取当前电机脉冲位置。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
position:
|
||||
type: integer
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: get_position参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_position:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
absolute: false
|
||||
acceleration: 10
|
||||
direction: CW
|
||||
pulses: 1000
|
||||
speed_rpm: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 位置模式运行。控制电机移动到指定脉冲位置或相对于当前位置移动指定脉冲数。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
absolute:
|
||||
default: false
|
||||
type: boolean
|
||||
acceleration:
|
||||
default: 10
|
||||
maximum: 255
|
||||
minimum: 0
|
||||
type: integer
|
||||
direction:
|
||||
default: CW
|
||||
enum:
|
||||
- CW
|
||||
- CCW
|
||||
type: string
|
||||
pulses:
|
||||
default: 1000
|
||||
type: integer
|
||||
speed_rpm:
|
||||
default: 60
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- pulses
|
||||
- speed_rpm
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_position参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_speed:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 10
|
||||
direction: CW
|
||||
speed_rpm: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 速度模式运行。控制电机以指定转速和方向持续转动。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 10
|
||||
maximum: 255
|
||||
minimum: 0
|
||||
type: integer
|
||||
direction:
|
||||
default: CW
|
||||
enum:
|
||||
- CW
|
||||
- CCW
|
||||
type: string
|
||||
speed_rpm:
|
||||
default: 60
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- speed_rpm
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_speed参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-rotate_quarter:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
direction: CW
|
||||
speed_rpm: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 电机旋转 1/4 圈 (阻塞式)。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
direction:
|
||||
default: CW
|
||||
enum:
|
||||
- CW
|
||||
- CCW
|
||||
type: string
|
||||
speed_rpm:
|
||||
default: 60
|
||||
minimum: 1
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: rotate_quarter参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_zero:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 将当前电机位置设为零点。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_zero参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-stop:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 立即停止电机运动。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_time:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
duration_s: 1.0
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 等待指定时间 (秒)。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
duration_s:
|
||||
default: 1.0
|
||||
minimum: 0
|
||||
type: number
|
||||
required:
|
||||
- duration_s
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_time参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.motor.ZDT_X42:ZDTX42Driver
|
||||
status_types:
|
||||
position: int
|
||||
status: str
|
||||
type: python
|
||||
config_info: []
|
||||
description: ZDT X42 闭环步进电机驱动。支持速度运行、精确位置控制、位置查询和清零功能。适用于各种需要精确运动控制的实验室自动化场景。
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
baudrate:
|
||||
default: 115200
|
||||
type: integer
|
||||
debug:
|
||||
default: false
|
||||
type: boolean
|
||||
device_id:
|
||||
default: 1
|
||||
type: integer
|
||||
port:
|
||||
type: string
|
||||
timeout:
|
||||
default: 0.5
|
||||
type: number
|
||||
required:
|
||||
- port
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
position:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- position
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -49,32 +49,7 @@ opcua_example:
|
||||
title: load_config参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_cache_stats:
|
||||
auto-refresh_node_values:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
@@ -92,32 +67,7 @@ opcua_example:
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: print_cache_stats参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-read_node:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
node_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
node_name:
|
||||
type: string
|
||||
required:
|
||||
- node_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: read_node参数
|
||||
title: refresh_node_values参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_node_value:
|
||||
@@ -149,9 +99,50 @@ opcua_example:
|
||||
title: set_node_value参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-start_node_refresh:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: start_node_refresh参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-stop_node_refresh:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop_node_refresh参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.device_comms.opcua_client.client:OpcUaClient
|
||||
status_types:
|
||||
cache_stats: dict
|
||||
node_value: String
|
||||
type: python
|
||||
config_info: []
|
||||
@@ -161,23 +152,15 @@ opcua_example:
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
cache_timeout:
|
||||
default: 5.0
|
||||
type: number
|
||||
config_path:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
subscription_interval:
|
||||
default: 500
|
||||
type: integer
|
||||
refresh_interval:
|
||||
default: 1.0
|
||||
type: number
|
||||
url:
|
||||
type: string
|
||||
use_subscription:
|
||||
default: true
|
||||
type: boolean
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
@@ -185,12 +168,9 @@ opcua_example:
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
cache_stats:
|
||||
type: object
|
||||
node_value:
|
||||
type: string
|
||||
required:
|
||||
- node_value
|
||||
- cache_stats
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -4,6 +4,81 @@ reaction_station.bioyond:
|
||||
- reaction_station_bioyond
|
||||
class:
|
||||
action_value_mappings:
|
||||
add_time_constraint:
|
||||
feedback: {}
|
||||
goal:
|
||||
duration: duration
|
||||
end_point: end_point
|
||||
end_step_key: end_step_key
|
||||
start_point: start_point
|
||||
start_step_key: start_step_key
|
||||
goal_default:
|
||||
duration: 0
|
||||
end_point: 0
|
||||
end_step_key: ''
|
||||
start_point: 0
|
||||
start_step_key: ''
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 添加时间约束 - 在两个工作流之间添加时间约束
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
duration:
|
||||
description: 时间(秒)
|
||||
type: integer
|
||||
end_point:
|
||||
default: Start
|
||||
description: 终点计时点 (Start=开始前, End=结束后)
|
||||
enum:
|
||||
- Start
|
||||
- End
|
||||
type: string
|
||||
end_step_key:
|
||||
description: 终点步骤Key (可选, 默认为空则自动选择)
|
||||
type: string
|
||||
start_point:
|
||||
default: Start
|
||||
description: 起点计时点 (Start=开始前, End=结束后)
|
||||
enum:
|
||||
- Start
|
||||
- End
|
||||
type: string
|
||||
start_step_key:
|
||||
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择)
|
||||
type: string
|
||||
required:
|
||||
- duration
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: add_time_constraint参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-clear_workflows:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: clear_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-create_order:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -131,6 +206,35 @@ reaction_station.bioyond:
|
||||
title: process_web_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_reactor_temperature:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
reactor_id: null
|
||||
temperature: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
reactor_id:
|
||||
type: integer
|
||||
temperature:
|
||||
type: number
|
||||
required:
|
||||
- reactor_id
|
||||
- temperature
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_reactor_temperature参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-skip_titration_steps:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -156,6 +260,27 @@ reaction_station.bioyond:
|
||||
title: skip_titration_steps参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-sync_workflow_sequence_from_bioyond:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: sync_workflow_sequence_from_bioyond参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -188,6 +313,33 @@ reaction_station.bioyond:
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sequence:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
value: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
value:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- value
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sequence参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_step_query:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -213,6 +365,36 @@ reaction_station.bioyond:
|
||||
title: workflow_step_query参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
clean_all_server_workflows:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
code: code
|
||||
message: message
|
||||
schema:
|
||||
description: 清空服务端所有非核心工作流 (保留核心流程)
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
code:
|
||||
description: 操作结果代码(1表示成功)
|
||||
type: integer
|
||||
message:
|
||||
description: 结果描述
|
||||
type: string
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: clean_all_server_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
drip_back:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -247,13 +429,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume
|
||||
@@ -353,13 +541,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume
|
||||
@@ -403,7 +597,7 @@ reaction_station.bioyond:
|
||||
label: Solvents Data From Calculation Node
|
||||
result: {}
|
||||
schema:
|
||||
description: 液体投料-溶剂。可以直接提供volume(μL),或通过solvents对象自动从additional_solvent(mL)计算volume。
|
||||
description: 液体投料-溶剂。可以直接提供volume(mL),或通过solvents对象自动从additional_solvent(mL)计算volume。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -423,15 +617,21 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟),默认360
|
||||
type: string
|
||||
titration_type:
|
||||
default: '1'
|
||||
description: 是否滴定(1=否, 2=是),默认1
|
||||
default: 'NO'
|
||||
description: 是否滴定(NO=否, YES=是),默认NO
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
default: '2'
|
||||
description: 是否观察 (1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否观察 (NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液量(μL)。可直接提供,或通过solvents参数自动计算
|
||||
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -504,15 +704,21 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟),默认90
|
||||
type: string
|
||||
titration_type:
|
||||
default: '2'
|
||||
description: 是否滴定(1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否滴定(NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
default: '2'
|
||||
description: 是否观察 (1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否观察 (NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume_formula:
|
||||
description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
||||
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
||||
type: string
|
||||
x_value:
|
||||
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
|
||||
@@ -560,13 +766,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume_formula:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume_formula
|
||||
@@ -680,6 +892,35 @@ reaction_station.bioyond:
|
||||
title: reactor_taken_out参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
scheduler_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 调度器启动结果,成功返回1,失败返回0
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
solid_feeding_vials:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -706,7 +947,11 @@ reaction_station.bioyond:
|
||||
description: 物料名称(用于获取试剂瓶位ID)
|
||||
type: string
|
||||
material_id:
|
||||
description: 粉末类型ID,1=盐(21分钟),2=面粉(27分钟),3=BTDA(38分钟)
|
||||
description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟)
|
||||
enum:
|
||||
- Salt
|
||||
- Flour
|
||||
- BTDA
|
||||
type: string
|
||||
temperature:
|
||||
description: 温度设定(°C)
|
||||
@@ -715,7 +960,10 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -730,10 +978,10 @@ reaction_station.bioyond:
|
||||
title: solid_feeding_vials参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station.reaction_station:BioyondReactionStation
|
||||
protocol_type: []
|
||||
status_types:
|
||||
workflow_sequence: String
|
||||
workflow_sequence: str
|
||||
type: python
|
||||
config_info: []
|
||||
description: Bioyond反应站
|
||||
@@ -753,9 +1001,7 @@ reaction_station.bioyond:
|
||||
data:
|
||||
properties:
|
||||
workflow_sequence:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: string
|
||||
required:
|
||||
- workflow_sequence
|
||||
type: object
|
||||
@@ -791,7 +1037,7 @@ reaction_station.reactor:
|
||||
title: update_metrics参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station.reaction_station:BioyondReactor
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
|
||||
148
unilabos/registry/devices/sensor.yaml
Normal file
148
unilabos/registry/devices/sensor.yaml
Normal file
@@ -0,0 +1,148 @@
|
||||
sensor.xkc_rs485:
|
||||
category:
|
||||
- sensor
|
||||
- separator
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-change_baudrate:
|
||||
goal:
|
||||
baud_code: 7
|
||||
handles: {}
|
||||
schema:
|
||||
description: '更改通讯波特率 (设置成功后无返回,且需手动切换波特率重连)。代码表 (16进制): 05=2400, 06=4800,
|
||||
07=9600, 08=14400, 09=19200, 0A=28800, 0C=57600, 0D=115200, 0E=128000,
|
||||
0F=256000'
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
baud_code:
|
||||
description: '波特率代码 (例如: 7 为 9600, 13 即 0x0D 为 115200)'
|
||||
type: integer
|
||||
required:
|
||||
- baud_code
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-change_device_id:
|
||||
goal:
|
||||
new_id: 1
|
||||
handles: {}
|
||||
schema:
|
||||
description: 修改传感器的 Modbus 从站地址
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
new_id:
|
||||
description: 新的从站地址 (1-254)
|
||||
maximum: 254
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- new_id
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-factory_reset:
|
||||
goal: {}
|
||||
handles: {}
|
||||
schema:
|
||||
description: 恢复出厂设置 (地址重置为 01)
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-read_level:
|
||||
goal: {}
|
||||
handles: {}
|
||||
schema:
|
||||
description: 直接读取当前液位及信号强度
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_threshold:
|
||||
goal:
|
||||
threshold: 300
|
||||
handles: {}
|
||||
schema:
|
||||
description: 设置液位判定阈值
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
threshold:
|
||||
type: integer
|
||||
required:
|
||||
- threshold
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_liquid:
|
||||
goal:
|
||||
target_state: true
|
||||
timeout: 120
|
||||
handles: {}
|
||||
schema:
|
||||
description: 实时检测电导率(RSSI)并等待用户指定的状态
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
target_state:
|
||||
default: true
|
||||
description: 目标状态 (True=有液, False=无液)
|
||||
type: boolean
|
||||
timeout:
|
||||
default: 120
|
||||
description: 超时时间 (秒)
|
||||
required:
|
||||
- target_state
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_level:
|
||||
goal:
|
||||
level: true
|
||||
timeout: 10
|
||||
handles: {}
|
||||
schema:
|
||||
description: 等待液位达到目标状态
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
level:
|
||||
type: boolean
|
||||
timeout:
|
||||
type: number
|
||||
required:
|
||||
- level
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.separator.xkc_sensor:XKCSensorDriver
|
||||
status_types:
|
||||
level: bool
|
||||
rssi: int
|
||||
type: python
|
||||
config_info: []
|
||||
description: XKC RS485 非接触式液位传感器 (Modbus RTU)
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
baudrate:
|
||||
default: 9600
|
||||
type: integer
|
||||
debug:
|
||||
default: false
|
||||
type: boolean
|
||||
device_id:
|
||||
default: 1
|
||||
type: integer
|
||||
port:
|
||||
type: string
|
||||
threshold:
|
||||
default: 300
|
||||
type: integer
|
||||
timeout:
|
||||
default: 3.0
|
||||
type: number
|
||||
required:
|
||||
- port
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -0,0 +1,170 @@
|
||||
# UniLabOS 资源注册架构详解
|
||||
|
||||
> **目标受众**: 主要开发 `unilabos/registry/devices` 抽象层的开发者
|
||||
> **最后更新**: 2026-01-11
|
||||
> **维护者**: Uni-Lab-OS 开发团队
|
||||
|
||||
本文档详细说明 UniLabOS 资源注册系统的架构、资源的完整生命周期,以及如何实现动态物料位置追踪。
|
||||
|
||||
---
|
||||
|
||||
## 📚 目录
|
||||
|
||||
- [核心概念](#核心概念)
|
||||
- [三层架构详解](#三层架构详解)
|
||||
- [资源注册机制](#资源注册机制)
|
||||
- [物料生命周期管理](#物料生命周期管理)
|
||||
- [动态物料位置追踪](#动态物料位置追踪)
|
||||
- [实战案例](#实战案例)
|
||||
- [常见问题排查](#常见问题排查)
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 1. Resources vs Registry
|
||||
|
||||
UniLabOS 采用**声明式注册**模式,将资源的**定义**(Python)与**注册信息**(YAML)分离:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ unilabos/resources (Python 实现) │
|
||||
│ - 定义资源的物理属性、行为和创建逻辑 │
|
||||
│ - 例如: 瓶子的尺寸、容量、材质 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ unilabos/registry/resources (YAML 注册表) │
|
||||
│ - 声明哪些资源可以被前端使用 │
|
||||
│ - 定义资源的分类、图标、初始化参数 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**为什么要分离?**
|
||||
|
||||
1. **解耦**: Python 代码可以定义无限多的资源类,但只有在 YAML 中注册的才能被前端识别
|
||||
2. **灵活性**: 无需修改 Python 代码,只需修改 YAML 就能添加/移除可用资源
|
||||
3. **可扩展性**: 第三方开发者可以通过 YAML 注册自己的资源,无需修改核心代码
|
||||
|
||||
---
|
||||
|
||||
## 三层架构详解
|
||||
|
||||
UniLabOS 资源系统采用**三层架构**,实现从前端UI到底层硬件的完整映射:
|
||||
|
||||
### 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 第1层: YAML 注册表 (registry/resources) │
|
||||
│ - 告诉系统"哪些资源可用" │
|
||||
│ - 前端通过此层获取可用资源列表 │
|
||||
│ - 文件: YB_bottle.yaml, YB_bottle_carriers.yaml │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ 第2层: Python 实现 (resources/bioyond) │
|
||||
│ - 定义资源的具体属性和行为 │
|
||||
│ - 创建资源实例的工厂函数 │
|
||||
│ - 文件: YB_bottles.py, YB_bottle_carriers.py │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ 第3层: Hardware/API 集成 (devices/workstation) │
|
||||
│ - 连接 Bioyond 系统 API │
|
||||
│ - 同步物料位置和状态 │
|
||||
│ - 文件: station.py, bioyond_rpc.py, config.py │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 第1层: YAML 注册表
|
||||
|
||||
#### YB_bottle.yaml - 单个瓶子注册
|
||||
|
||||
```yaml
|
||||
YB_5ml_fenyeping:
|
||||
category:
|
||||
- yb3 # 系统分类
|
||||
- YB_bottle # 资源类型
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping # Python 函数路径
|
||||
type: pylabrobot # 框架类型
|
||||
description: YB_5ml_fenyeping # 前端显示名称
|
||||
handles: []
|
||||
icon: '' # 图标路径
|
||||
init_param_schema: {} # 初始化参数 schema
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
**作用:**
|
||||
- 前端通过读取此文件知道有一个叫 "YB_5ml_fenyeping" 的资源
|
||||
- 用户拖拽时,系统会调用 `YB_bottles:YB_5ml_fenyeping()` 创建实例
|
||||
|
||||
#### YB_bottle_carriers.yaml - 载架(容器)注册
|
||||
|
||||
```yaml
|
||||
YB_5ml_fenyepingban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban
|
||||
type: pylabrobot
|
||||
description: YB_5ml_fenyepingban # 5ml分液瓶板
|
||||
```
|
||||
|
||||
**作用:**
|
||||
- 载架是容器,里面可以放多个瓶子
|
||||
- 例如: `YB_5ml_fenyepingban` 是一个 4x2 布局的板,可以放 8 个 5ml 瓶子
|
||||
|
||||
### 第2层: Python 实现
|
||||
|
||||
#### YB_bottles.py - 瓶子工厂函数
|
||||
|
||||
```python
|
||||
def YB_5ml_fenyeping(
|
||||
name: str,
|
||||
diameter: float = 20.0, # 直径 (mm)
|
||||
height: float = 50.0, # 高度 (mm)
|
||||
max_volume: float = 5000.0, # 最大容量 (μL)
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
\"\"\"创建5ml分液瓶\"\"\"
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_5ml_fenyeping", # ⭐ 与 YAML 中的名称对应
|
||||
)
|
||||
```
|
||||
|
||||
**关键点:**
|
||||
- 函数名 `YB_5ml_fenyeping` 必须与 YAML 中的 `module` 路径末尾一致
|
||||
- 返回一个 `Bottle` 对象(PyLabRobot 资源类型)
|
||||
- `model` 字段用于在 Bioyond 系统中识别资源类型
|
||||
|
||||
**详细文档请参考完整版 README**
|
||||
|
||||
---
|
||||
|
||||
## 相关文件索引
|
||||
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 功能 | 路径 |
|
||||
|------|------|------|
|
||||
| `YB_bottle.yaml` | 瓶子注册表 | `unilabos/registry/resources/bioyond/` |
|
||||
| `YB_bottle_carriers.yaml` | 载架注册表 | `unilabos/registry/resources/bioyond/` |
|
||||
| `deck.yaml` | Deck注册表 | `unilabos/registry/resources/bioyond/` |
|
||||
| `YB_bottles.py` | 瓶子实现 | `unilabos/resources/bioyond/` |
|
||||
| `YB_bottle_carriers.py` | 载架实现 | `unilabos/resources/bioyond/` |
|
||||
| `YB_warehouses.py` | 仓库实现 | `unilabos/resources/bioyond/` |
|
||||
| `decks.py` | Deck布局 | `unilabos/resources/bioyond/` |
|
||||
| `station.py` | 物料同步 | `unilabos/devices/workstation/bioyond_studio/` |
|
||||
| `config.py` | UUID映射 | `unilabos/devices/workstation/bioyond_studio/` |
|
||||
|
||||
### 仓库相关文档
|
||||
|
||||
- [README_WAREHOUSE.md](../../resources/bioyond/README_WAREHOUSE.md) - 仓库系统开发指南
|
||||
|
||||
---
|
||||
|
||||
**维护者:** Uni-Lab-OS 开发团队
|
||||
**最后更新:** 2026-01-11
|
||||
92
unilabos/registry/resources/bioyond/YB_bottle.yaml
Normal file
92
unilabos/registry/resources/bioyond/YB_bottle.yaml
Normal file
@@ -0,0 +1,92 @@
|
||||
YB_20ml_fenyeping:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_20ml_fenyeping
|
||||
type: pylabrobot
|
||||
description: YB_20ml_fenyeping
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_5ml_fenyeping:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping
|
||||
type: pylabrobot
|
||||
description: YB_5ml_fenyeping
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_jia_yang_tou_da:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da
|
||||
type: pylabrobot
|
||||
description: YB_jia_yang_tou_da
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_pei_ye_da_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_da_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_pei_ye_da_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_pei_ye_xiao_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_pei_ye_xiao_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_qiang_tou:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_qiang_tou
|
||||
type: pylabrobot
|
||||
description: YB_qiang_tou
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_ye_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_ye_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
182
unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml
Normal file
182
unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml
Normal file
@@ -0,0 +1,182 @@
|
||||
YB_100ml_yeti:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_100ml_yeti
|
||||
type: pylabrobot
|
||||
description: YB_100ml_yeti
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_20ml_fenyepingban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_20ml_fenyepingban
|
||||
type: pylabrobot
|
||||
description: YB_20ml_fenyepingban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_5ml_fenyepingban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban
|
||||
type: pylabrobot
|
||||
description: YB_5ml_fenyepingban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_6StockCarrier:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier
|
||||
type: pylabrobot
|
||||
description: YB_6StockCarrier
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_6VialCarrier:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier
|
||||
type: pylabrobot
|
||||
description: YB_6VialCarrier
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_gao_nian_ye_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_gao_nian_ye_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_gao_nian_ye_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_gaonianye:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_gaonianye
|
||||
type: pylabrobot
|
||||
description: YB_gaonianye
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_jia_yang_tou_da_Carrier:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_Carrier
|
||||
type: pylabrobot
|
||||
description: YB_jia_yang_tou_da_Carrier
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_peiyepingdaban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingdaban
|
||||
type: pylabrobot
|
||||
description: YB_peiyepingdaban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_peiyepingxiaoban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban
|
||||
type: pylabrobot
|
||||
description: YB_peiyepingxiaoban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_qiang_tou_he:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_qiang_tou_he
|
||||
type: pylabrobot
|
||||
description: YB_qiang_tou_he
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_shi_pei_qi_kuai:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_shi_pei_qi_kuai
|
||||
type: pylabrobot
|
||||
description: YB_shi_pei_qi_kuai
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_ye:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_ye
|
||||
type: pylabrobot
|
||||
description: YB_ye_Bottle_Carrier
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_ye_100ml_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_ye_100ml_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_ye_100ml_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
@@ -46,3 +46,16 @@ BIOYOND_PolymerStation_8StockCarrier:
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
BIOYOND_PolymerStation_TipBox:
|
||||
category:
|
||||
- bottle_carriers
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_TipBox
|
||||
type: pylabrobot
|
||||
description: BIOYOND_PolymerStation_TipBox (4x6布局,24个枪头孔位)
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
@@ -20,6 +20,17 @@ BIOYOND_PolymerStation_Liquid_Vial:
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
BIOYOND_PolymerStation_Measurement_Vial:
|
||||
category:
|
||||
- bottles
|
||||
class:
|
||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Measurement_Vial
|
||||
type: pylabrobot
|
||||
description: 聚合站-测量小瓶(测密度)
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
BIOYOND_PolymerStation_Reactor:
|
||||
category:
|
||||
- bottles
|
||||
@@ -71,14 +82,3 @@ BIOYOND_PolymerStation_Solution_Beaker:
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
BIOYOND_PolymerStation_TipBox:
|
||||
category:
|
||||
- bottles
|
||||
- tip_boxes
|
||||
class:
|
||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_TipBox
|
||||
type: pylabrobot
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
|
||||
@@ -22,15 +22,27 @@ BIOYOND_PolymerReactionStation_Deck:
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
YB_Deck11:
|
||||
BIOYOND_YB_Deck:
|
||||
category:
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.resources.bioyond.decks:YB_Deck
|
||||
type: pylabrobot
|
||||
description: BIOYOND PolymerReactionStation Deck
|
||||
description: BIOYOND ElectrolyteFormulationStation Deck
|
||||
handles: []
|
||||
icon: 配液站.webp
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
CoincellDeck:
|
||||
category:
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:YH_Deck
|
||||
type: pylabrobot
|
||||
description: YIHUA CoinCellAssembly Deck
|
||||
handles: []
|
||||
icon: koudian.webp
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
56
unilabos/resources/battery/bottle_carriers.py
Normal file
56
unilabos/resources/battery/bottle_carriers.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||
from unilabos.resources.bioyond.YB_bottles import (
|
||||
YB_pei_ye_xiao_Bottle,
|
||||
)
|
||||
# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
|
||||
|
||||
def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier:
|
||||
"""12瓶载架 - 2x6布局"""
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 120.0
|
||||
carrier_size_y = 250.0
|
||||
carrier_size_z = 50.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 35.0
|
||||
bottle_spacing_x = 35.0 # X方向间距
|
||||
bottle_spacing_y = 35.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (6 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=2,
|
||||
num_items_y=6,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="Electrolyte_12VialCarrier",
|
||||
)
|
||||
carrier.num_items_x = 2
|
||||
carrier.num_items_y = 6
|
||||
carrier.num_items_z = 1
|
||||
for i in range(12):
|
||||
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
|
||||
return carrier
|
||||
195
unilabos/resources/battery/electrode_sheet.py
Normal file
195
unilabos/resources/battery/electrode_sheet.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from typing import Any, Dict, Optional, TypedDict
|
||||
|
||||
from pylabrobot.resources import Resource as ResourcePLR
|
||||
from pylabrobot.resources import Container
|
||||
|
||||
|
||||
electrode_colors = {
|
||||
"PositiveCan": "#ff0000",
|
||||
"PositiveElectrode": "#cc3333",
|
||||
"NegativeCan": "#000000",
|
||||
"NegativeElectrode": "#666666",
|
||||
"SpringWasher": "#8b7355",
|
||||
"FlatWasher": "a9a9a9",
|
||||
"AluminumFoil": "#ffcccc",
|
||||
"Battery": "#00ff00",
|
||||
}
|
||||
|
||||
class ElectrodeSheetState(TypedDict):
|
||||
diameter: float # 直径 (mm)
|
||||
thickness: float # 厚度 (mm)
|
||||
mass: float # 质量 (g)
|
||||
material_type: str # 材料类型(铜、铝、不锈钢、弹簧钢等)
|
||||
color: str # 材料类型对应的颜色
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
|
||||
class ElectrodeSheet(ResourcePLR):
|
||||
"""极片类 - 包含正负极片、隔膜、弹片、垫片、铝箔等所有片状材料"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "极片",
|
||||
size_x: float = 10,
|
||||
size_y: float = 10,
|
||||
size_z: float = 10,
|
||||
category: str = "electrode_sheet",
|
||||
model: Optional[str] = None,
|
||||
**kwargs
|
||||
):
|
||||
"""初始化极片
|
||||
|
||||
Args:
|
||||
name: 极片名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
category: 类别
|
||||
model: 型号
|
||||
**kwargs: 其他参数传递给父类
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
model=model,
|
||||
**kwargs
|
||||
)
|
||||
self._unilabos_state: ElectrodeSheetState = ElectrodeSheetState(
|
||||
diameter=14,
|
||||
thickness=0.1,
|
||||
mass=0.5,
|
||||
material_type="copper",
|
||||
color="#8b4513",
|
||||
info=None
|
||||
)
|
||||
|
||||
# TODO: 这个还要不要?给self._unilabos_state赋值的?
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
#序列化
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
|
||||
def PositiveCan(name: str) -> ElectrodeSheet:
|
||||
"""创建正极壳"""
|
||||
sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=3.0, model="PositiveCan")
|
||||
sheet.load_state({"diameter": 20.0, "thickness": 0.5, "mass": 0.5, "material_type": "aluminum", "color": electrode_colors["PositiveCan"], "info": None})
|
||||
return sheet
|
||||
|
||||
|
||||
def PositiveElectrode(name: str) -> ElectrodeSheet:
|
||||
"""创建正极片"""
|
||||
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="PositiveElectrode")
|
||||
sheet.load_state({"material_type": "positive_electrode", "color": electrode_colors["PositiveElectrode"]})
|
||||
return sheet
|
||||
|
||||
|
||||
def NegativeCan(name: str) -> ElectrodeSheet:
|
||||
"""创建负极壳"""
|
||||
sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=2.0, model="NegativeCan")
|
||||
sheet.load_state({"material_type": "steel", "color": electrode_colors["NegativeCan"]})
|
||||
return sheet
|
||||
|
||||
|
||||
def NegativeElectrode(name: str) -> ElectrodeSheet:
|
||||
"""创建负极片"""
|
||||
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="NegativeElectrode")
|
||||
sheet.load_state({"material_type": "negative_electrode", "color": electrode_colors["NegativeElectrode"]})
|
||||
return sheet
|
||||
|
||||
|
||||
def SpringWasher(name: str) -> ElectrodeSheet:
|
||||
"""创建弹片"""
|
||||
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.5, model="SpringWasher")
|
||||
sheet.load_state({"material_type": "spring_steel", "color": electrode_colors["SpringWasher"]})
|
||||
return sheet
|
||||
|
||||
|
||||
def FlatWasher(name: str) -> ElectrodeSheet:
|
||||
"""创建垫片"""
|
||||
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.2, model="FlatWasher")
|
||||
sheet.load_state({"material_type": "steel", "color": electrode_colors["FlatWasher"]})
|
||||
return sheet
|
||||
|
||||
|
||||
def AluminumFoil(name: str) -> ElectrodeSheet:
|
||||
"""创建铝箔"""
|
||||
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.05, model="AluminumFoil")
|
||||
sheet.load_state({"material_type": "aluminum", "color": electrode_colors["AluminumFoil"]})
|
||||
return sheet
|
||||
|
||||
|
||||
class BatteryState(TypedDict):
|
||||
color: str # 材料类型对应的颜色
|
||||
electrolyte_name: str
|
||||
data_electrolyte_code: str
|
||||
open_circuit_voltage: float
|
||||
assembly_pressure: float
|
||||
electrolyte_volume: float
|
||||
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
|
||||
class Battery(Container):
|
||||
"""电池类 - 包含组装好的电池"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "电池",
|
||||
size_x: float = 12,
|
||||
size_y: float = 12,
|
||||
size_z: float = 6,
|
||||
category: str = "battery",
|
||||
model: Optional[str] = None,
|
||||
**kwargs
|
||||
):
|
||||
"""初始化电池
|
||||
|
||||
Args:
|
||||
name: 电池名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
category: 类别
|
||||
model: 型号
|
||||
**kwargs: 其他参数传递给父类
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
model=model,
|
||||
**kwargs
|
||||
)
|
||||
self._unilabos_state: BatteryState = BatteryState(
|
||||
color=electrode_colors["Battery"],
|
||||
electrolyte_name="无",
|
||||
data_electrolyte_code="",
|
||||
open_circuit_voltage=0.0,
|
||||
assembly_pressure=0.0,
|
||||
electrolyte_volume=0.0,
|
||||
info=None
|
||||
)
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
#序列化
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
344
unilabos/resources/battery/magazine.py
Normal file
344
unilabos/resources/battery/magazine.py
Normal file
@@ -0,0 +1,344 @@
|
||||
from typing import Dict, List, Optional, OrderedDict, Union, Callable
|
||||
import math
|
||||
|
||||
from pylabrobot.resources.coordinate import Coordinate
|
||||
from pylabrobot.resources import Resource, ResourceStack, ItemizedResource
|
||||
from pylabrobot.resources.carrier import create_homogeneous_resources
|
||||
|
||||
from unilabos.resources.battery.electrode_sheet import (
|
||||
PositiveCan, PositiveElectrode,
|
||||
NegativeCan, NegativeElectrode,
|
||||
SpringWasher, FlatWasher,
|
||||
AluminumFoil,
|
||||
Battery
|
||||
)
|
||||
|
||||
|
||||
class Magazine(ResourceStack):
|
||||
"""子弹夹洞位类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
direction: str = 'z',
|
||||
resources: Optional[List[Resource]] = None,
|
||||
max_sheets: int = 100,
|
||||
**kwargs
|
||||
):
|
||||
"""初始化子弹夹洞位
|
||||
|
||||
Args:
|
||||
name: 洞位名称
|
||||
direction: 堆叠方向
|
||||
resources: 资源列表
|
||||
max_sheets: 最大极片数量
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
direction=direction,
|
||||
resources=resources,
|
||||
)
|
||||
self.max_sheets = max_sheets
|
||||
|
||||
@property
|
||||
def size_x(self) -> float:
|
||||
return self.get_size_x()
|
||||
|
||||
@property
|
||||
def size_y(self) -> float:
|
||||
return self.get_size_y()
|
||||
|
||||
@property
|
||||
def size_z(self) -> float:
|
||||
return self.get_size_z()
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
"size_x": self.size_x or 10.0,
|
||||
"size_y": self.size_y or 10.0,
|
||||
"size_z": self.size_z or 10.0,
|
||||
"max_sheets": self.max_sheets,
|
||||
}
|
||||
|
||||
|
||||
class MagazineHolder(ItemizedResource):
|
||||
"""子弹夹类 - 有多个洞位,每个洞位放多个极片"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
ordered_items: Optional[Dict[str, Magazine]] = None,
|
||||
ordering: Optional[OrderedDict[str, str]] = None,
|
||||
hole_diameter: float = 14.0,
|
||||
hole_depth: float = 10.0,
|
||||
max_sheets_per_hole: int = 100,
|
||||
cross_section_type: str = "circle",
|
||||
category: str = "magazine_holder",
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""初始化子弹夹
|
||||
|
||||
Args:
|
||||
name: 子弹夹名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
max_sheets_per_hole: 每个洞位最大极片数量
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
# 保存洞位的直径和深度
|
||||
self.hole_diameter = hole_diameter
|
||||
self.hole_depth = hole_depth
|
||||
self.max_sheets_per_hole = max_sheets_per_hole
|
||||
self.cross_section_type = cross_section_type
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
"hole_diameter": self.hole_diameter,
|
||||
"hole_depth": self.hole_depth,
|
||||
"max_sheets_per_hole": self.max_sheets_per_hole,
|
||||
"cross_section_type": self.cross_section_type,
|
||||
}
|
||||
|
||||
|
||||
def magazine_factory(
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
locations: List[Coordinate],
|
||||
klasses: Optional[List[Callable[[str], str]]] = None,
|
||||
hole_diameter: float = 14.0,
|
||||
hole_depth: float = 10.0,
|
||||
max_sheets_per_hole: int = 100,
|
||||
category: str = "magazine_holder",
|
||||
model: Optional[str] = None,
|
||||
) -> 'MagazineHolder':
|
||||
"""工厂函数:创建子弹夹
|
||||
|
||||
Args:
|
||||
name: 子弹夹名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
locations: 洞位坐标列表
|
||||
klasses: 每个洞位中极片的类列表
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
max_sheets_per_hole: 每个洞位最大极片数量
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
for loc in locations:
|
||||
loc.x -= hole_diameter / 2
|
||||
loc.y -= hole_diameter / 2
|
||||
|
||||
# 创建洞位
|
||||
_sites = create_homogeneous_resources(
|
||||
klass=Magazine,
|
||||
locations=locations,
|
||||
resource_size_x=hole_diameter,
|
||||
resource_size_y=hole_diameter,
|
||||
name_prefix=name,
|
||||
max_sheets=max_sheets_per_hole,
|
||||
)
|
||||
|
||||
# 生成编号键
|
||||
keys = [f"A{i+1}" for i in range(len(locations))]
|
||||
sites = dict(zip(keys, _sites.values()))
|
||||
|
||||
holder = MagazineHolder(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=sites,
|
||||
hole_diameter=hole_diameter,
|
||||
hole_depth=hole_depth,
|
||||
max_sheets_per_hole=max_sheets_per_hole,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
if klasses is not None:
|
||||
for i, klass in enumerate(klasses):
|
||||
hole_key = keys[i]
|
||||
hole = holder.children[i]
|
||||
for j in reversed(range(max_sheets_per_hole)):
|
||||
item_name = f"{hole_key}_sheet{j+1}"
|
||||
item = klass(name=item_name)
|
||||
hole.assign_child_resource(item)
|
||||
return holder
|
||||
|
||||
|
||||
def MagazineHolder_6_Cathode(
|
||||
name: str,
|
||||
size_x: float = 80.0,
|
||||
size_y: float = 80.0,
|
||||
size_z: float = 40.0,
|
||||
hole_diameter: float = 14.0,
|
||||
hole_depth: float = 10.0,
|
||||
hole_spacing: float = 20.0,
|
||||
max_sheets_per_hole: int = 100,
|
||||
) -> MagazineHolder:
|
||||
"""创建6孔子弹夹 - 六边形排布"""
|
||||
center_x = size_x / 2
|
||||
center_y = size_y / 2
|
||||
|
||||
locations = []
|
||||
|
||||
# 周围6个孔,按六边形排布
|
||||
for i in range(6):
|
||||
angle = i * 60 * math.pi / 180 # 每60度一个孔
|
||||
x = center_x + hole_spacing * math.cos(angle)
|
||||
y = center_y + hole_spacing * math.sin(angle)
|
||||
locations.append(Coordinate(x, y, size_z - hole_depth))
|
||||
|
||||
return magazine_factory(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
locations=locations,
|
||||
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
|
||||
hole_diameter=hole_diameter,
|
||||
hole_depth=hole_depth,
|
||||
max_sheets_per_hole=max_sheets_per_hole,
|
||||
category="magazine_holder",
|
||||
model="MagazineHolder_6_Cathode",
|
||||
)
|
||||
|
||||
|
||||
def MagazineHolder_6_Anode(
|
||||
name: str,
|
||||
size_x: float = 80.0,
|
||||
size_y: float = 80.0,
|
||||
size_z: float = 40.0,
|
||||
hole_diameter: float = 14.0,
|
||||
hole_depth: float = 10.0,
|
||||
hole_spacing: float = 20.0,
|
||||
max_sheets_per_hole: int = 100,
|
||||
) -> MagazineHolder:
|
||||
"""创建6孔子弹夹 - 六边形排布"""
|
||||
center_x = size_x / 2
|
||||
center_y = size_y / 2
|
||||
|
||||
locations = []
|
||||
|
||||
# 周围6个孔,按六边形排布
|
||||
for i in range(6):
|
||||
angle = i * 60 * math.pi / 180 # 每60度一个孔
|
||||
x = center_x + hole_spacing * math.cos(angle)
|
||||
y = center_y + hole_spacing * math.sin(angle)
|
||||
locations.append(Coordinate(x, y, size_z - hole_depth))
|
||||
|
||||
return magazine_factory(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
locations=locations,
|
||||
klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan],
|
||||
hole_diameter=hole_diameter,
|
||||
hole_depth=hole_depth,
|
||||
max_sheets_per_hole=max_sheets_per_hole,
|
||||
category="magazine_holder",
|
||||
model="MagazineHolder_6_Anode",
|
||||
)
|
||||
|
||||
|
||||
def MagazineHolder_6_Battery(
|
||||
name: str,
|
||||
size_x: float = 80.0,
|
||||
size_y: float = 80.0,
|
||||
size_z: float = 40.0,
|
||||
hole_diameter: float = 14.0,
|
||||
hole_depth: float = 10.0,
|
||||
hole_spacing: float = 20.0,
|
||||
max_sheets_per_hole: int = 100,
|
||||
) -> MagazineHolder:
|
||||
"""创建6孔子弹夹 - 六边形排布"""
|
||||
center_x = size_x / 2
|
||||
center_y = size_y / 2
|
||||
|
||||
locations = []
|
||||
|
||||
# 周围6个孔,按六边形排布
|
||||
for i in range(6):
|
||||
angle = i * 60 * math.pi / 180 # 每60度一个孔
|
||||
x = center_x + hole_spacing * math.cos(angle)
|
||||
y = center_y + hole_spacing * math.sin(angle)
|
||||
locations.append(Coordinate(x, y, size_z - hole_depth))
|
||||
|
||||
return magazine_factory(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
locations=locations,
|
||||
klasses=None, # 初始化时,不放入装好的电池
|
||||
hole_diameter=hole_diameter,
|
||||
hole_depth=hole_depth,
|
||||
max_sheets_per_hole=max_sheets_per_hole,
|
||||
category="magazine_holder",
|
||||
model="MagazineHolder_6_Battery",
|
||||
)
|
||||
|
||||
|
||||
def MagazineHolder_4_Cathode(
|
||||
name: str,
|
||||
) -> MagazineHolder:
|
||||
"""创建4孔子弹夹 - 正方形四角排布"""
|
||||
size_x: float = 80.0
|
||||
size_y: float = 80.0
|
||||
size_z: float = 10.0
|
||||
hole_diameter: float = 14.0
|
||||
hole_depth: float = 10.0
|
||||
hole_spacing: float = 25.0
|
||||
max_sheets_per_hole: int = 100
|
||||
|
||||
# 计算4个洞位的坐标(正方形四角排布)
|
||||
center_x = size_x / 2
|
||||
center_y = size_y / 2
|
||||
offset = hole_spacing / 2
|
||||
|
||||
locations = [
|
||||
Coordinate(center_x - offset, center_y - offset, size_z - hole_depth), # 左下
|
||||
Coordinate(center_x + offset, center_y - offset, size_z - hole_depth), # 右下
|
||||
Coordinate(center_x - offset, center_y + offset, size_z - hole_depth), # 左上
|
||||
Coordinate(center_x + offset, center_y + offset, size_z - hole_depth), # 右上
|
||||
]
|
||||
|
||||
return magazine_factory(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
locations=locations,
|
||||
klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode],
|
||||
hole_diameter=hole_diameter,
|
||||
hole_depth=hole_depth,
|
||||
max_sheets_per_hole=max_sheets_per_hole,
|
||||
category="magazine_holder",
|
||||
model="MagazineHolder_4_Cathode",
|
||||
)
|
||||
548
unilabos/resources/bioyond/README_WAREHOUSE.md
Normal file
548
unilabos/resources/bioyond/README_WAREHOUSE.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# Bioyond 仓库系统开发指南
|
||||
|
||||
本文档详细说明 Bioyond 仓库(Warehouse)系统的架构、配置和使用方法,帮助开发者快速理解和维护仓库相关代码。
|
||||
|
||||
## 📚 目录
|
||||
|
||||
- [系统架构](#系统架构)
|
||||
- [核心概念](#核心概念)
|
||||
- [三层映射关系](#三层映射关系)
|
||||
- [warehouse_factory 详解](#warehouse_factory-详解)
|
||||
- [创建新仓库](#创建新仓库)
|
||||
- [常见问题](#常见问题)
|
||||
- [调试技巧](#调试技巧)
|
||||
|
||||
---
|
||||
|
||||
## 系统架构
|
||||
|
||||
Bioyond 仓库系统采用**三层架构**,实现从前端显示到后端 API 的完整映射:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 前端显示层 (YB_warehouses.py) │
|
||||
│ - warehouse_factory 自动生成库位网格 │
|
||||
│ - 生成库位名称:A01, B02, C03... │
|
||||
│ - 存储在 WareHouse.sites 字典中 │
|
||||
└────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Deck 布局层 (decks.py) │
|
||||
│ - 定义仓库在 Deck 上的物理位置 │
|
||||
│ - 组织多个仓库形成完整布局 │
|
||||
└────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ UUID 映射层 (config.py) │
|
||||
│ - 将库位名称映射到 Bioyond 系统 UUID │
|
||||
│ - 用于 API 调用时的物料入库操作 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 仓库(Warehouse)
|
||||
|
||||
仓库是一个**三维网格**,用于存放物料。由以下参数定义:
|
||||
|
||||
- **num_items_x**: 列数(X 轴)
|
||||
- **num_items_y**: 行数(Y 轴)
|
||||
- **num_items_z**: 层数(Z 轴)
|
||||
|
||||
例如:`5行×3列×1层` = 5×3×1 = 15个库位
|
||||
|
||||
### 库位(Site)
|
||||
|
||||
库位是仓库中的单个存储位置,由**字母行+数字列**命名:
|
||||
|
||||
- **字母行**:A, B, C, D, E, F...(对应 Y 轴)
|
||||
- **数字列**:01, 02, 03, 04...(对应 X 轴或 Z 轴)
|
||||
|
||||
示例:`A01`, `B02`, `C03`
|
||||
|
||||
### 布局模式(Layout)
|
||||
|
||||
控制库位的排序和 Y 坐标计算:
|
||||
|
||||
| 模式 | 说明 | 生成顺序 | Y 坐标计算 | 显示效果 |
|
||||
|------|------|----------|-----------|---------|
|
||||
| `col-major` | 列优先(默认) | A01, B01, C01, A02... | `dy + (num_y - row - 1) * item_dy` | A 可能在下 |
|
||||
| `row-major` | 行优先 | A01, A02, A03, B01... | `dy + row * item_dy` | **A 在上** ✓ |
|
||||
|
||||
**重要:** 使用 `row-major` 可以避免上下颠倒问题!
|
||||
|
||||
---
|
||||
|
||||
## 三层映射关系
|
||||
|
||||
### 示例:手动传递窗右(A01-E03)
|
||||
|
||||
#### 1️⃣ 前端显示层 - [`YB_warehouses.py`](YB_warehouses.py)
|
||||
|
||||
```python
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
"""创建 5行×3列×1层 仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列
|
||||
num_items_y=5, # 5行
|
||||
num_items_z=1, # 1层
|
||||
row_offset=row_offset,
|
||||
layout="row-major",
|
||||
)
|
||||
```
|
||||
|
||||
**自动生成的库位:** A01, A02, A03, B01, B02, B03, ..., E01, E02, E03
|
||||
|
||||
#### 2️⃣ Deck 布局层 - [`decks.py`](decks.py)
|
||||
|
||||
```python
|
||||
self.warehouses = {
|
||||
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"手动传递窗右": Coordinate(4160.0, 877.0, 0.0),
|
||||
}
|
||||
```
|
||||
|
||||
**作用:**
|
||||
- 创建仓库实例
|
||||
- 设置在 Deck 上的物理坐标
|
||||
|
||||
#### 3️⃣ UUID 映射层 - [`config.py`](../../devices/workstation/bioyond_studio/config.py)
|
||||
|
||||
```python
|
||||
WAREHOUSE_MAPPING = {
|
||||
"手动传递窗右": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe",
|
||||
# ... 其他库位
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**作用:**
|
||||
- 用户拖拽物料到"手动传递窗右"的"A01"位置时
|
||||
- 系统查找 `WAREHOUSE_MAPPING["手动传递窗右"]["site_uuids"]["A01"]`
|
||||
- 获取 UUID `"3a19deae-2c7a-36f5-5e41-02c5b66feaea"`
|
||||
- 调用 Bioyond API 将物料入库到该 UUID 位置
|
||||
|
||||
---
|
||||
|
||||
## 实际配置案例
|
||||
|
||||
### 案例:手动传递窗左/右的完整配置
|
||||
|
||||
本案例展示如何为"手动传递窗右"和"手动传递窗左"建立完整的三层映射。
|
||||
|
||||
#### 背景需求
|
||||
- **手动传递窗右**: 需要 A01-E03(5行×3列=15个库位)
|
||||
- **手动传递窗左**: 需要 F01-J03(5行×3列=15个库位)
|
||||
- 这两个仓库共享同一个物理堆栈的 UUID("手动堆栈")
|
||||
|
||||
#### 实施步骤
|
||||
|
||||
**1️⃣ 修复前端布局** - [`YB_warehouses.py`](YB_warehouses.py)
|
||||
|
||||
```python
|
||||
# 创建新的 5×3×1 仓库函数(之前是错误的 1×3×3)
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
"""创建5行×3列×1层仓库,支持行偏移生成不同字母行"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列
|
||||
num_items_y=5, # 5行 ← 修正
|
||||
num_items_z=1, # 1层 ← 修正
|
||||
row_offset=row_offset, # ← 支持 F-J 行
|
||||
layout="row-major", # ← 避免上下颠倒
|
||||
)
|
||||
```
|
||||
|
||||
**2️⃣ 更新 Deck 配置** - [`decks.py`](decks.py)
|
||||
|
||||
```python
|
||||
from unilabos.resources.bioyond.YB_warehouses import (
|
||||
bioyond_warehouse_5x3x1, # 新增导入
|
||||
)
|
||||
|
||||
class BIOYOND_YB_Deck(Deck):
|
||||
def setup(self) -> None:
|
||||
self.warehouses = {
|
||||
# 修改前: bioyond_warehouse_1x3x3 (错误尺寸)
|
||||
# 修改后: bioyond_warehouse_5x3x1 (正确尺寸)
|
||||
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03
|
||||
"手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03
|
||||
}
|
||||
```
|
||||
|
||||
**3️⃣ 添加 UUID 映射** - [`config.py`](../../devices/workstation/bioyond_studio/config.py)
|
||||
|
||||
```python
|
||||
WAREHOUSE_MAPPING = {
|
||||
# 保持原有的"手动堆栈"配置不变(A01-J03共30个库位)
|
||||
"手动堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||
# ... A02-E03 共15个
|
||||
"F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a",
|
||||
# ... F02-J03 共15个
|
||||
}
|
||||
},
|
||||
|
||||
# [新增] 手动传递窗右 - 复用"手动堆栈"的 A01-E03 UUID
|
||||
"手动传递窗右": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea", # ← 与手动堆栈A01相同
|
||||
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe",
|
||||
"A03": "3a19deae-2c7a-5876-c454-6b7e224ca927",
|
||||
"B01": "3a19deae-2c7a-2426-6d71-e9de3cb158b1",
|
||||
"B02": "3a19deae-2c7a-79b0-5e44-efaafd1e4cf3",
|
||||
"B03": "3a19deae-2c7a-b9eb-f4e3-e308e0cf839a",
|
||||
"C01": "3a19deae-2c7a-32bc-768e-556647e292f3",
|
||||
"C02": "3a19deae-2c7a-e97a-8484-f5a4599447c4",
|
||||
"C03": "3a19deae-2c7a-3056-6504-10dc73fbc276",
|
||||
"D01": "3a19deae-2c7a-ffad-875e-8c4cda61d440",
|
||||
"D02": "3a19deae-2c7a-61be-601c-b6fb5610499a",
|
||||
"D03": "3a19deae-2c7a-c0f7-05a7-e3fe2491e560",
|
||||
"E01": "3a19deae-2c7a-a6f4-edd1-b436a7576363",
|
||||
"E02": "3a19deae-2c7a-4367-96dd-1ca2186f4910",
|
||||
"E03": "3a19deae-2c7a-b163-2219-23df15200311",
|
||||
}
|
||||
},
|
||||
|
||||
# [新增] 手动传递窗左 - 复用"手动堆栈"的 F01-J03 UUID
|
||||
"手动传递窗左": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a", # ← 与手动堆栈F01相同
|
||||
"F02": "3a19deae-2c7a-a194-ea63-8b342b8d8679",
|
||||
"F03": "3a19deae-2c7a-f7c4-12bd-425799425698",
|
||||
"G01": "3a19deae-2c7a-0b56-72f1-8ab86e53b955",
|
||||
"G02": "3a19deae-2c7a-204e-95ed-1f1950f28343",
|
||||
"G03": "3a19deae-2c7a-392b-62f1-4907c66343f8",
|
||||
"H01": "3a19deae-2c7a-5602-e876-d27aca4e3201",
|
||||
"H02": "3a19deae-2c7a-f15c-70e0-25b58a8c9702",
|
||||
"H03": "3a19deae-2c7a-780b-8965-2e1345f7e834",
|
||||
"I01": "3a19deae-2c7a-8849-e172-07de14ede928",
|
||||
"I02": "3a19deae-2c7a-4772-a37f-ff99270bafc0",
|
||||
"I03": "3a19deae-2c7a-cce7-6e4a-25ea4a2068c4",
|
||||
"J01": "3a19deae-2c7a-1848-de92-b5d5ed054cc6",
|
||||
"J02": "3a19deae-2c7a-1d45-b4f8-6f866530e205",
|
||||
"J03": "3a19deae-2c7a-f237-89d9-8fe19025dee9"
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 关键要点
|
||||
|
||||
1. **UUID 可以复用**: 三个仓库(手动堆栈、手动传递窗右、手动传递窗左)可以共享相同的物理库位 UUID
|
||||
2. **库位名称必须匹配**: 前端生成的库位名称(如 F01)必须与 config.py 中的键名完全一致
|
||||
3. **row_offset 的妙用**:
|
||||
- `row_offset=0` → 生成 A-E 行
|
||||
- `row_offset=5` → 生成 F-J 行(跳过前5个字母)
|
||||
|
||||
#### 验证结果
|
||||
|
||||
配置完成后,拖拽测试:
|
||||
|
||||
| 拖拽位置 | 前端库位 | 查找路径 | UUID | 结果 |
|
||||
|---------|---------|---------|------|------|
|
||||
| 手动传递窗右/A01 | A01 | `WAREHOUSE_MAPPING["手动传递窗右"]["site_uuids"]["A01"]` | `3a19...eaea` | ✅ 正确入库 |
|
||||
| 手动传递窗左/F01 | F01 | `WAREHOUSE_MAPPING["手动传递窗左"]["site_uuids"]["F01"]` | `3a19...c4a` | ✅ 正确入库 |
|
||||
| 手动堆栈/A01 | A01 | `WAREHOUSE_MAPPING["手动堆栈"]["site_uuids"]["A01"]` | `3a19...eaea` | ✅ 仍然正常 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## warehouse_factory 详解
|
||||
|
||||
### 函数签名
|
||||
|
||||
```python
|
||||
def warehouse_factory(
|
||||
name: str,
|
||||
num_items_x: int = 1, # 列数
|
||||
num_items_y: int = 4, # 行数
|
||||
num_items_z: int = 4, # 层数
|
||||
dx: float = 137.0, # X 起始偏移
|
||||
dy: float = 96.0, # Y 起始偏移
|
||||
dz: float = 120.0, # Z 起始偏移
|
||||
item_dx: float = 10.0, # X 间距
|
||||
item_dy: float = 10.0, # Y 间距
|
||||
item_dz: float = 10.0, # Z 间距
|
||||
col_offset: int = 0, # 列偏移(影响数字)
|
||||
row_offset: int = 0, # 行偏移(影响字母)
|
||||
layout: str = "col-major", # 布局模式
|
||||
) -> WareHouse:
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
#### 尺寸参数
|
||||
- **num_items_x, y, z**: 定义仓库的网格尺寸
|
||||
- **注意**: 当 `num_items_z > 1` 时,Z 轴会被映射为数字列
|
||||
|
||||
#### 位置参数
|
||||
- **dx, dy, dz**: 第一个库位的起始坐标
|
||||
- **item_dx, dy, dz**: 库位之间的间距
|
||||
|
||||
#### 偏移参数
|
||||
- **col_offset**: 列起始偏移,用于生成 A05-D08 等命名
|
||||
```python
|
||||
col_offset=4 # 生成 A05, A06, A07, A08
|
||||
```
|
||||
|
||||
- **row_offset**: 行起始偏移,用于生成 F01-J03 等命名
|
||||
```python
|
||||
row_offset=5 # 生成 F01, F02, F03(跳过 A-E)
|
||||
```
|
||||
|
||||
#### 布局参数
|
||||
- **layout**:
|
||||
- `"col-major"`: 列优先(默认),可能导致上下颠倒
|
||||
- `"row-major"`: 行优先,**推荐使用**,A 显示在上
|
||||
|
||||
### 库位生成逻辑
|
||||
|
||||
```python
|
||||
# row-major 模式(推荐)
|
||||
keys = [f"{LETTERS[j + row_offset]}{i + 1 + col_offset:02d}"
|
||||
for j in range(num_y)
|
||||
for i in range(num_x)]
|
||||
|
||||
# 示例:num_y=2, num_x=3, row_offset=0, col_offset=0
|
||||
# 生成:A01, A02, A03, B01, B02, B03
|
||||
```
|
||||
|
||||
### Y 坐标计算
|
||||
|
||||
```python
|
||||
if layout == "row-major":
|
||||
# A 在上(Y 较小)
|
||||
y = dy + row * item_dy
|
||||
else:
|
||||
# A 在下(Y 较大)- 不推荐
|
||||
y = dy + (num_items_y - row - 1) * item_dy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 创建新仓库
|
||||
|
||||
### 步骤 1: 在 YB_warehouses.py 中创建函数
|
||||
|
||||
```python
|
||||
def bioyond_warehouse_3x4x1(name: str) -> WareHouse:
|
||||
"""创建 3行×4列×1层 仓库
|
||||
|
||||
布局:
|
||||
A01 | A02 | A03 | A04
|
||||
B01 | B02 | B03 | B04
|
||||
C01 | C02 | C03 | C04
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, # 4列
|
||||
num_items_y=3, # 3行
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=120.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
layout="row-major", # ⭐ 推荐使用
|
||||
)
|
||||
```
|
||||
|
||||
### 步骤 2: 在 decks.py 中使用
|
||||
|
||||
```python
|
||||
# 1. 导入函数
|
||||
from unilabos.resources.bioyond.YB_warehouses import (
|
||||
bioyond_warehouse_3x4x1, # 新增
|
||||
)
|
||||
|
||||
# 2. 在 setup() 中添加
|
||||
self.warehouses = {
|
||||
"我的新仓库": bioyond_warehouse_3x4x1("我的新仓库"),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"我的新仓库": Coordinate(100.0, 200.0, 0.0),
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 3: 在 config.py 中配置 UUID(可选)
|
||||
|
||||
```python
|
||||
WAREHOUSE_MAPPING = {
|
||||
"我的新仓库": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "从 Bioyond 系统获取的 UUID",
|
||||
"A02": "从 Bioyond 系统获取的 UUID",
|
||||
# ... 其他 11 个库位
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** 如果不需要拖拽入库功能,可跳过此步骤。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 为什么库位显示上下颠倒(C 在上,A 在下)?
|
||||
|
||||
**原因:** 使用了默认的 `col-major` 布局。
|
||||
|
||||
**解决:** 在 `warehouse_factory` 中添加 `layout="row-major"`
|
||||
|
||||
```python
|
||||
return warehouse_factory(
|
||||
...
|
||||
layout="row-major", # ← 添加这行
|
||||
)
|
||||
```
|
||||
|
||||
### Q2: 我需要 1×3×3 还是 3×3×1?
|
||||
|
||||
**判断方法:**
|
||||
- **1×3×3**: 1列×3行×3**层**(垂直堆叠,有高度)
|
||||
- **3×3×1**: 3行×3列×1**层**(平面网格)
|
||||
|
||||
**推荐:** 大多数情况使用 `X×Y×1`(平面网格)更直观。
|
||||
|
||||
### Q3: 如何生成 F01-J03 而非 A01-E03?
|
||||
|
||||
**方法:** 使用 `row_offset` 参数
|
||||
|
||||
```python
|
||||
bioyond_warehouse_5x3x1("仓库名", row_offset=5)
|
||||
# row_offset=5 跳过 A-E,从 F 开始
|
||||
```
|
||||
|
||||
### Q4: 拖拽物料后找不到 UUID 怎么办?
|
||||
|
||||
**检查清单:**
|
||||
1. `config.py` 中是否有该仓库的配置?
|
||||
2. 仓库名称是否完全匹配?
|
||||
3. 库位名称(如 A01)是否在 `site_uuids` 中?
|
||||
|
||||
**示例错误:**
|
||||
```python
|
||||
# decks.py
|
||||
"手动传递窗右": bioyond_warehouse_5x3x1(...)
|
||||
|
||||
# config.py - ❌ 名称不匹配
|
||||
"手动传递窗": { ... } # 缺少"右"字
|
||||
```
|
||||
|
||||
### Q5: 库位重叠怎么办?
|
||||
|
||||
**原因:** 间距(`item_dx/dy/dz`)太小。
|
||||
|
||||
**解决:** 增大间距参数
|
||||
|
||||
```python
|
||||
item_dx=150.0, # 增大 X 间距
|
||||
item_dy=130.0, # 增大 Y 间距
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 1. 查看生成的库位
|
||||
|
||||
```python
|
||||
warehouse = bioyond_warehouse_5x3x1("测试仓库")
|
||||
print(list(warehouse.sites.keys()))
|
||||
# 输出:['A01', 'A02', 'A03', 'B01', 'B02', ...]
|
||||
```
|
||||
|
||||
### 2. 检查库位坐标
|
||||
|
||||
```python
|
||||
for name, site in warehouse.sites.items():
|
||||
print(f"{name}: {site.location}")
|
||||
# 输出:
|
||||
# A01: Coordinate(x=10.0, y=10.0, z=120.0)
|
||||
# A02: Coordinate(x=147.0, y=10.0, z=120.0)
|
||||
# ...
|
||||
```
|
||||
|
||||
### 3. 验证 UUID 映射
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.bioyond_studio.config import WAREHOUSE_MAPPING
|
||||
|
||||
warehouse_name = "手动传递窗右"
|
||||
location_code = "A01"
|
||||
|
||||
if warehouse_name in WAREHOUSE_MAPPING:
|
||||
uuid = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"].get(location_code)
|
||||
print(f"{warehouse_name}/{location_code} → {uuid}")
|
||||
else:
|
||||
print(f"❌ 未找到仓库: {warehouse_name}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件关系图
|
||||
|
||||
```
|
||||
unilabos/
|
||||
├── resources/
|
||||
│ ├── warehouse.py # warehouse_factory 核心实现
|
||||
│ └── bioyond/
|
||||
│ ├── YB_warehouses.py # ⭐ 仓库函数定义
|
||||
│ ├── decks.py # ⭐ Deck 布局配置
|
||||
│ └── README_WAREHOUSE.md # 📖 本文档
|
||||
└── devices/
|
||||
└── workstation/
|
||||
└── bioyond_studio/
|
||||
├── config.py # ⭐ UUID 映射配置
|
||||
└── bioyond_cell/
|
||||
└── bioyond_cell_workstation.py # 业务逻辑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 版本历史
|
||||
|
||||
- **v1.1** (2026-01-08): 补充实际配置案例
|
||||
- 添加"手动传递窗右"和"手动传递窗左"的完整配置示例
|
||||
- 展示 UUID 复用的实际应用
|
||||
- 说明三个仓库共享物理堆栈的配置方法
|
||||
|
||||
- **v1.0** (2026-01-07): 初始版本
|
||||
- 新增 `row_offset` 参数支持
|
||||
- 创建 `bioyond_warehouse_5x3x1` 和 `bioyond_warehouse_2x2x1`
|
||||
- 修复多个仓库的上下颠倒问题
|
||||
|
||||
---
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [warehouse.py](../warehouse.py) - 核心工厂函数实现
|
||||
- [YB_warehouses.py](YB_warehouses.py) - 所有仓库定义
|
||||
- [decks.py](decks.py) - Deck 布局配置
|
||||
- [config.py](../../devices/workstation/bioyond_studio/config.py) - UUID 映射
|
||||
|
||||
---
|
||||
|
||||
**维护者:** Uni-Lab-OS 开发团队
|
||||
**最后更新:** 2026-01-07
|
||||
653
unilabos/resources/bioyond/YB_bottle_carriers.py
Normal file
653
unilabos/resources/bioyond/YB_bottle_carriers.py
Normal file
@@ -0,0 +1,653 @@
|
||||
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||
from unilabos.resources.bioyond.YB_bottles import (
|
||||
YB_jia_yang_tou_da,
|
||||
YB_ye_Bottle,
|
||||
YB_ye_100ml_Bottle,
|
||||
YB_gao_nian_ye_Bottle,
|
||||
YB_5ml_fenyeping,
|
||||
YB_20ml_fenyeping,
|
||||
YB_pei_ye_xiao_Bottle,
|
||||
YB_pei_ye_da_Bottle,
|
||||
YB_qiang_tou,
|
||||
)
|
||||
# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
|
||||
|
||||
def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier:
|
||||
"""6瓶载架 - 2x3布局"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 50.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 30.0
|
||||
bottle_spacing_x = 42.0 # X方向间距
|
||||
bottle_spacing_y = 35.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3,
|
||||
num_items_y=2,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="Electrolyte_6VialCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
# for i in range(6):
|
||||
# carrier[i] = YB_Solid_Vial(f"{name}_vial_{i+1}")
|
||||
return carrier
|
||||
|
||||
|
||||
def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
|
||||
"""1瓶载架 - 单个中央位置"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 100.0
|
||||
|
||||
# 烧杯尺寸
|
||||
beaker_diameter = 80.0
|
||||
|
||||
# 计算中央位置
|
||||
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||
center_z = 5.0
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(center_x, center_y, center_z)],
|
||||
resource_size_x=beaker_diameter,
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="Electrolyte_1BottleCarrier",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
# carrier[0] = YB_Solution_Beaker(f"{name}_beaker_1")
|
||||
return carrier
|
||||
|
||||
|
||||
def YB_6StockCarrier(name: str) -> BottleCarrier:
|
||||
"""6瓶载架 - 2x3布局"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 50.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 20.0
|
||||
bottle_spacing_x = 42.0 # X方向间距
|
||||
bottle_spacing_y = 35.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3,
|
||||
num_items_y=2,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="6StockCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
|
||||
# for i in range(6):
|
||||
# carrier[i] = YB_Solid_Stock(f"{name}_vial_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
|
||||
def YB_6VialCarrier(name: str) -> BottleCarrier:
|
||||
"""6瓶载架 - 2x3布局"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 50.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 30.0
|
||||
bottle_spacing_x = 42.0 # X方向间距
|
||||
bottle_spacing_y = 35.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3,
|
||||
num_items_y=2,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="6VialCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
|
||||
# for i in range(3):
|
||||
# carrier[i] = YB_Solid_Vial(f"{name}_solidvial_{ordering[i]}")
|
||||
# for i in range(3, 6):
|
||||
# carrier[i] = YB_Liquid_Vial(f"{name}_liquidvial_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
# 1瓶载架 - 单个中央位置
|
||||
def YB_ye(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 20.0
|
||||
|
||||
# 烧杯尺寸
|
||||
beaker_diameter = 60.0
|
||||
|
||||
# 计算中央位置
|
||||
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||
center_z = 5.0
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(center_x, center_y, center_z)],
|
||||
resource_size_x=beaker_diameter,
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_ye",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_ye_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
|
||||
|
||||
# 高粘液瓶载架 - 单个中央位置
|
||||
def YB_gaonianye(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 20.0
|
||||
|
||||
# 烧杯尺寸
|
||||
beaker_diameter = 60.0
|
||||
|
||||
# 计算中央位置
|
||||
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||
center_z = 5.0
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(center_x, center_y, center_z)],
|
||||
resource_size_x=beaker_diameter,
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_gaonianye",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_gao_nian_ye_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
|
||||
|
||||
# 100ml液体瓶载架 - 单个中央位置
|
||||
def YB_100ml_yeti(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 20.0
|
||||
|
||||
# 烧杯尺寸
|
||||
beaker_diameter = 60.0
|
||||
|
||||
# 计算中央位置
|
||||
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||
center_z = 5.0
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(center_x, center_y, center_z)],
|
||||
resource_size_x=beaker_diameter,
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_100ml_yeti",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_ye_100ml_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
|
||||
# 5ml分液瓶板 - 4x2布局,8个位置
|
||||
def YB_5ml_fenyepingban(name: str) -> BottleCarrier:
|
||||
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 50.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 15.0
|
||||
bottle_spacing_x = 42.0 # X方向间距
|
||||
bottle_spacing_y = 35.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=4,
|
||||
num_items_y=2,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_5ml_fenyepingban",
|
||||
)
|
||||
carrier.num_items_x = 4
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||
for i in range(8):
|
||||
carrier[i] = YB_5ml_fenyeping(f"{name}_vial_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
# 20ml分液瓶板 - 4x2布局,8个位置
|
||||
def YB_20ml_fenyepingban(name: str) -> BottleCarrier:
|
||||
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 70.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 20.0
|
||||
bottle_spacing_x = 42.0 # X方向间距
|
||||
bottle_spacing_y = 35.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=4,
|
||||
num_items_y=2,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_20ml_fenyepingban",
|
||||
)
|
||||
carrier.num_items_x = 4
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||
for i in range(8):
|
||||
carrier[i] = YB_20ml_fenyeping(f"{name}_vial_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
# 配液瓶(小)板 - 4x2布局,8个位置
|
||||
def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
|
||||
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 65.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 35.0
|
||||
bottle_spacing_x = 42.0 # X方向间距
|
||||
bottle_spacing_y = 35.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=4,
|
||||
num_items_y=2,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_peiyepingxiaoban",
|
||||
)
|
||||
carrier.num_items_x = 4
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||
for i in range(8):
|
||||
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
|
||||
# 配液瓶(大)板 - 2x2布局,4个位置
|
||||
def YB_peiyepingdaban(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 95.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 55.0
|
||||
bottle_spacing_x = 60.0 # X方向间距
|
||||
bottle_spacing_y = 60.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=2,
|
||||
num_items_y=2,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_peiyepingdaban",
|
||||
)
|
||||
carrier.num_items_x = 2
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
ordering = ["A1", "A2", "B1", "B2"]
|
||||
for i in range(4):
|
||||
carrier[i] = YB_pei_ye_da_Bottle(f"{name}_bottle_{ordering[i]}")
|
||||
return carrier
|
||||
|
||||
# 加样头(大)板 - 1x1布局,1个位置
|
||||
def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier:
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 95.0
|
||||
|
||||
# 瓶位尺寸
|
||||
bottle_diameter = 35.0
|
||||
bottle_spacing_x = 42.0 # X方向间距
|
||||
bottle_spacing_y = 35.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (1 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (1 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=1,
|
||||
num_items_y=1,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_jia_yang_tou_da_Carrier",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = YB_jia_yang_tou_da(f"{name}_head_1")
|
||||
return carrier
|
||||
|
||||
|
||||
def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
|
||||
"""适配器块 - 单个中央位置"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 30.0
|
||||
|
||||
# 适配器尺寸
|
||||
adapter_diameter = 80.0
|
||||
|
||||
# 计算中央位置
|
||||
center_x = (carrier_size_x - adapter_diameter) / 2
|
||||
center_y = (carrier_size_y - adapter_diameter) / 2
|
||||
center_z = 0.0
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(center_x, center_y, center_z)],
|
||||
resource_size_x=adapter_diameter,
|
||||
resource_size_y=adapter_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="YB_shi_pei_qi_kuai",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
# 适配器块本身不包含瓶子,只是一个支撑结构
|
||||
return carrier
|
||||
|
||||
|
||||
def YB_qiang_tou_he(name: str) -> BottleCarrier:
|
||||
"""枪头盒 - 8x12布局,96个位置"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 55.0
|
||||
|
||||
# 枪头尺寸
|
||||
tip_diameter = 10.0
|
||||
tip_spacing_x = 9.0 # X方向间距
|
||||
tip_spacing_y = 9.0 # Y方向间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (12 - 1) * tip_spacing_x - tip_diameter) / 2
|
||||
start_y = (carrier_size_y - (8 - 1) * tip_spacing_y - tip_diameter) / 2
|
||||
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=tip_spacing_x,
|
||||
item_dy=tip_spacing_y,
|
||||
size_x=tip_diameter,
|
||||
size_y=tip_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_qiang_tou_he",
|
||||
)
|
||||
carrier.num_items_x = 12
|
||||
carrier.num_items_y = 8
|
||||
carrier.num_items_z = 1
|
||||
# 创建96个枪头
|
||||
for i in range(96):
|
||||
row = chr(65 + i // 12) # A-H
|
||||
col = (i % 12) + 1 # 1-12
|
||||
carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}")
|
||||
return carrier
|
||||
|
||||
163
unilabos/resources/bioyond/YB_bottles.py
Normal file
163
unilabos/resources/bioyond/YB_bottles.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||
# 工厂函数
|
||||
"""加样头(大)"""
|
||||
def YB_jia_yang_tou_da(
|
||||
name: str,
|
||||
diameter: float = 20.0,
|
||||
height: float = 100.0,
|
||||
max_volume: float = 30000.0, # 30mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建粉末瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,# 未知
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_jia_yang_tou_da",
|
||||
)
|
||||
|
||||
"""液1x1"""
|
||||
def YB_ye_Bottle(
|
||||
name: str,
|
||||
diameter: float = 40.0,
|
||||
height: float = 70.0,
|
||||
max_volume: float = 50000.0, # 50mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建液体瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_ye_Bottle",
|
||||
)
|
||||
|
||||
"""100ml液体"""
|
||||
def YB_ye_100ml_Bottle(
|
||||
name: str,
|
||||
diameter: float = 50.0,
|
||||
height: float = 90.0,
|
||||
max_volume: float = 100000.0, # 100mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建100ml液体瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_100ml_yeti",
|
||||
)
|
||||
|
||||
"""高粘液"""
|
||||
def YB_gao_nian_ye_Bottle(
|
||||
name: str,
|
||||
diameter: float = 40.0,
|
||||
height: float = 70.0,
|
||||
max_volume: float = 50000.0, # 50mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建高粘液瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="High_Viscosity_Liquid",
|
||||
)
|
||||
|
||||
"""5ml分液瓶"""
|
||||
def YB_5ml_fenyeping(
|
||||
name: str,
|
||||
diameter: float = 20.0,
|
||||
height: float = 50.0,
|
||||
max_volume: float = 5000.0, # 5mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建5ml分液瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_5ml_fenyeping",
|
||||
)
|
||||
|
||||
"""20ml分液瓶"""
|
||||
def YB_20ml_fenyeping(
|
||||
name: str,
|
||||
diameter: float = 30.0,
|
||||
height: float = 65.0,
|
||||
max_volume: float = 20000.0, # 20mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建20ml分液瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_20ml_fenyeping",
|
||||
)
|
||||
|
||||
"""配液瓶(小)"""
|
||||
def YB_pei_ye_xiao_Bottle(
|
||||
name: str,
|
||||
diameter: float = 35.0,
|
||||
height: float = 60.0,
|
||||
max_volume: float = 30000.0, # 30mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建配液瓶(小)"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_pei_ye_xiao_Bottle",
|
||||
)
|
||||
|
||||
"""配液瓶(大)"""
|
||||
def YB_pei_ye_da_Bottle(
|
||||
name: str,
|
||||
diameter: float = 55.0,
|
||||
height: float = 100.0,
|
||||
max_volume: float = 150000.0, # 150mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建配液瓶(大)"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_pei_ye_da_Bottle",
|
||||
)
|
||||
|
||||
"""枪头"""
|
||||
def YB_qiang_tou(
|
||||
name: str,
|
||||
diameter: float = 10.0,
|
||||
height: float = 50.0,
|
||||
max_volume: float = 1000.0, # 1mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建枪头"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_qiang_tou",
|
||||
)
|
||||
384
unilabos/resources/bioyond/YB_warehouses.py
Normal file
384
unilabos/resources/bioyond/YB_warehouses.py
Normal file
@@ -0,0 +1,384 @@
|
||||
from unilabos.resources.warehouse import WareHouse, warehouse_factory
|
||||
|
||||
# ================ 反应站相关堆栈 ================
|
||||
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
"""创建BioYond 4x4x1仓库 (左侧堆栈: A01~D04)
|
||||
|
||||
使用行优先排序,前端展示为:
|
||||
A01 | A02 | A03 | A04
|
||||
B01 | B02 | B03 | B04
|
||||
C01 | C02 | C03 | C04
|
||||
D01 | D02 | D03 | D04
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, # 4列
|
||||
num_items_y=4, # 4行
|
||||
num_items_z=1,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=147.0,
|
||||
item_dy=106.0,
|
||||
item_dz=130.0,
|
||||
category="warehouse",
|
||||
col_offset=0, # 从01开始: A01, A02, A03, A04
|
||||
layout="row-major", # ⭐ 改为行优先排序
|
||||
)
|
||||
|
||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
"""创建BioYond 4x4x1仓库 (右侧堆栈: A05~D08)"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
num_items_z=1,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=147.0,
|
||||
item_dy=106.0,
|
||||
item_dz=130.0,
|
||||
category="warehouse",
|
||||
col_offset=4, # 从05开始: A05, A06, A07, A08
|
||||
layout="row-major", # ⭐ 改为行优先排序
|
||||
)
|
||||
|
||||
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
|
||||
"""创建测量小瓶仓库(测密度) A01~B03"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列(01-03)
|
||||
num_items_y=2, # 2行(A-B)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=40.0,
|
||||
item_dy=40.0,
|
||||
item_dz=50.0,
|
||||
# 用更小的 resource_size 来表现 "小点的孔位"
|
||||
resource_size_x=30.0,
|
||||
resource_size_y=30.0,
|
||||
resource_size_z=12.0,
|
||||
category="warehouse",
|
||||
col_offset=0,
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
"""创建BioYond站内试剂存放堆栈(A01~A02, 1行×2列)"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=2, # 2列(01-02)
|
||||
num_items_y=1, # 1行(A)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
|
||||
"""创建BioYond站内Tip盒堆栈(A01~B03, 2行×3列)"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列(01-03)
|
||||
num_items_y=2, # 2行(A-B)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
col_offset=0,
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_liquid_preparation(name: str) -> WareHouse:
|
||||
"""已弃用,创建BioYond移液站内10%分装液体准备仓库(A01~B04)"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, # 4列(01-04)
|
||||
num_items_y=2, # 2行(A-B)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
col_offset=0,
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
# ================ 配液站相关堆栈 ================
|
||||
|
||||
def bioyond_warehouse_reagent_stack(name: str) -> WareHouse:
|
||||
"""创建BioYond 试剂堆栈 2x4x1 (2行×4列: A01-A04, B01-B04)
|
||||
|
||||
使用行优先排序,前端展示为:
|
||||
A01 | A02 | A03 | A04
|
||||
B01 | B02 | B03 | B04
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, # 4列 (01-04)
|
||||
num_items_y=2, # 2行 (A-B)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=147.0,
|
||||
item_dy=106.0,
|
||||
item_dz=130.0,
|
||||
category="warehouse",
|
||||
col_offset=0, # 从01开始
|
||||
layout="row-major", # ⭐ 使用行优先排序: A01,A02,A03,A04, B01,B02,B03,B04
|
||||
)
|
||||
|
||||
# 定义bioyond的堆栈
|
||||
|
||||
# =================== Other ===================
|
||||
|
||||
def bioyond_warehouse_1x4x2(name: str) -> WareHouse:
|
||||
"""创建BioYond 4x2x1仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1,
|
||||
num_items_y=4,
|
||||
num_items_z=2,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
removed_positions=None
|
||||
)
|
||||
|
||||
def bioyond_warehouse_1x2x2(name: str) -> WareHouse:
|
||||
"""创建BioYond 1x2x2仓库(1列×2行×2层)- 旧版本,已弃用
|
||||
|
||||
布局(2层):
|
||||
层1: A01
|
||||
B01
|
||||
层2: A02
|
||||
B02
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1,
|
||||
num_items_y=2,
|
||||
num_items_z=2,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
layout="row-major", # 使用行优先避免上下颠倒
|
||||
)
|
||||
|
||||
def bioyond_warehouse_2x2x1(name: str) -> WareHouse:
|
||||
"""创建BioYond 2x2x1仓库(2行×2列×1层)
|
||||
|
||||
布局:
|
||||
A01 | A02
|
||||
B01 | B02
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=2, # 2列
|
||||
num_items_y=2, # 2行
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
layout="row-major", # 使用行优先避免上下颠倒
|
||||
)
|
||||
|
||||
|
||||
def bioyond_warehouse_10x1x1(name: str) -> WareHouse:
|
||||
"""创建BioYond 10x1x1仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=10,
|
||||
num_items_y=1,
|
||||
num_items_z=1,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_1x3x3(name: str) -> WareHouse:
|
||||
"""创建BioYond 1x3x3仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1,
|
||||
num_items_y=3,
|
||||
num_items_z=3,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=120.0, # 增大Y方向间距以避免重叠
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
"""创建BioYond 5x3x1仓库(5行×3列×1层)
|
||||
|
||||
标准布局(row_offset=0):
|
||||
A01 | A02 | A03
|
||||
B01 | B02 | B03
|
||||
C01 | C02 | C03
|
||||
D01 | D02 | D03
|
||||
E01 | E02 | E03
|
||||
|
||||
带偏移布局(row_offset=5):
|
||||
F01 | F02 | F03
|
||||
G01 | G02 | G03
|
||||
H01 | H02 | H03
|
||||
I01 | I02 | I03
|
||||
J01 | J02 | J03
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列
|
||||
num_items_y=5, # 5行
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=120.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
col_offset=0,
|
||||
row_offset=row_offset, # 支持行偏移
|
||||
layout="row-major", # 使用行优先避免颠倒
|
||||
)
|
||||
|
||||
|
||||
def bioyond_warehouse_3x3x1(name: str) -> WareHouse:
|
||||
"""创建BioYond 3x3x1仓库(3行×3列×1层)
|
||||
|
||||
布局:
|
||||
A01 | A02 | A03
|
||||
B01 | B02 | B03
|
||||
C01 | C02 | C03
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3,
|
||||
num_items_y=3,
|
||||
num_items_z=1,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
layout="row-major", # ⭐ 使用行优先避免上下颠倒
|
||||
)
|
||||
def bioyond_warehouse_2x1x3(name: str) -> WareHouse:
|
||||
"""创建BioYond 2x1x3仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=2,
|
||||
num_items_y=1,
|
||||
num_items_z=3,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
)
|
||||
|
||||
|
||||
def bioyond_warehouse_5x1x1(name: str) -> WareHouse:
|
||||
"""已弃用:创建BioYond 5x1x1仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=5,
|
||||
num_items_y=1,
|
||||
num_items_z=1,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_3x3x1_2(name: str) -> WareHouse:
|
||||
"""已弃用:创建BioYond 3x3x1仓库"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3,
|
||||
num_items_y=3,
|
||||
num_items_z=1,
|
||||
dx=12.0,
|
||||
dy=12.0,
|
||||
dz=12.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse:
|
||||
"""创建BioYond开关盖加液模块台面"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=2,
|
||||
num_items_y=5,
|
||||
num_items_z=1,
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
removed_positions=None
|
||||
)
|
||||
|
||||
def bioyond_warehouse_1x8x4(name: str) -> WareHouse:
|
||||
"""创建BioYond 8x4x1反应站堆栈(A01~D08)"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=8, # 8列(01-08)
|
||||
num_items_y=4, # 4行(A-D)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=147.0,
|
||||
item_dy=106.0,
|
||||
item_dz=130.0,
|
||||
category="warehouse",
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
|
||||
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d, Container
|
||||
|
||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||
from unilabos.resources.bioyond.bottles import (
|
||||
@@ -9,6 +9,28 @@ from unilabos.resources.bioyond.bottles import (
|
||||
BIOYOND_PolymerStation_Reagent_Bottle,
|
||||
BIOYOND_PolymerStation_Flask,
|
||||
)
|
||||
|
||||
|
||||
def BIOYOND_PolymerStation_Tip(name: str, size_x: float = 8.0, size_y: float = 8.0, size_z: float = 50.0) -> Container:
|
||||
"""创建单个枪头资源
|
||||
|
||||
Args:
|
||||
name: 枪头名称
|
||||
size_x: 枪头宽度 (mm)
|
||||
size_y: 枪头长度 (mm)
|
||||
size_z: 枪头高度 (mm)
|
||||
|
||||
Returns:
|
||||
Container: 枪头容器
|
||||
"""
|
||||
return Container(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category="tip",
|
||||
model="BIOYOND_PolymerStation_Tip",
|
||||
)
|
||||
# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
|
||||
|
||||
@@ -322,3 +344,88 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1")
|
||||
return carrier
|
||||
|
||||
|
||||
def BIOYOND_PolymerStation_TipBox(
|
||||
name: str,
|
||||
size_x: float = 127.76, # 枪头盒宽度
|
||||
size_y: float = 85.48, # 枪头盒长度
|
||||
size_z: float = 100.0, # 枪头盒高度
|
||||
barcode: str = None,
|
||||
) -> BottleCarrier:
|
||||
"""创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
|
||||
|
||||
Args:
|
||||
name: 枪头盒名称
|
||||
size_x: 枪头盒宽度 (mm)
|
||||
size_y: 枪头盒长度 (mm)
|
||||
size_z: 枪头盒高度 (mm)
|
||||
barcode: 条形码
|
||||
|
||||
Returns:
|
||||
BottleCarrier: 包含24个枪头孔位的枪头盒载架
|
||||
|
||||
布局说明:
|
||||
- 4行×6列 (A-D, 1-6)
|
||||
- 枪头孔位间距: 18mm (x方向) × 18mm (y方向)
|
||||
- 起始位置居中对齐
|
||||
- 索引顺序: 列优先 (0=A1, 1=B1, 2=C1, 3=D1, 4=A2, ...)
|
||||
"""
|
||||
# 枪头孔位参数
|
||||
num_cols = 6 # 1-6 (x方向)
|
||||
num_rows = 4 # A-D (y方向)
|
||||
tip_diameter = 8.0 # 枪头孔位直径
|
||||
tip_spacing_x = 18.0 # 列间距 (增加到18mm,更宽松)
|
||||
tip_spacing_y = 18.0 # 行间距 (增加到18mm,更宽松)
|
||||
|
||||
# 计算起始位置 (居中对齐)
|
||||
total_width = (num_cols - 1) * tip_spacing_x + tip_diameter
|
||||
total_height = (num_rows - 1) * tip_spacing_y + tip_diameter
|
||||
start_x = (size_x - total_width) / 2
|
||||
start_y = (size_y - total_height) / 2
|
||||
|
||||
# 使用 create_ordered_items_2d 创建孔位
|
||||
# create_ordered_items_2d 返回的 key 是数字索引: 0, 1, 2, ...
|
||||
# 顺序是列优先: 先y后x (即 0=A1, 1=B1, 2=C1, 3=D1, 4=A2, 5=B2, ...)
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=num_cols,
|
||||
num_items_y=num_rows,
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0,
|
||||
item_dx=tip_spacing_x,
|
||||
item_dy=tip_spacing_y,
|
||||
size_x=tip_diameter,
|
||||
size_y=tip_diameter,
|
||||
size_z=50.0, # 枪头深度
|
||||
)
|
||||
|
||||
# 更新 sites 中每个 ResourceHolder 的名称
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
# 创建枪头盒载架
|
||||
# 注意:不设置 category,使用默认的 "bottle_carrier",这样前端会显示为完整的矩形载架
|
||||
tip_box = BottleCarrier(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
sites=sites, # 直接使用数字索引的 sites
|
||||
model="BIOYOND_PolymerStation_TipBox",
|
||||
)
|
||||
|
||||
# 设置自定义属性
|
||||
tip_box.barcode = barcode
|
||||
tip_box.tip_count = 24 # 4行×6列
|
||||
tip_box.num_items_x = num_cols
|
||||
tip_box.num_items_y = num_rows
|
||||
tip_box.num_items_z = 1
|
||||
|
||||
# ⭐ 枪头盒不需要放入子资源
|
||||
# 与其他 carrier 不同,枪头盒在 Bioyond 中是一个整体
|
||||
# 不需要追踪每个枪头的状态,保持为空的 ResourceHolder 即可
|
||||
# 这样前端会显示24个空槽位,可以用于放置枪头
|
||||
|
||||
return tip_box
|
||||
|
||||
@@ -116,7 +116,9 @@ def BIOYOND_PolymerStation_TipBox(
|
||||
size_z: float = 100.0, # 枪头盒高度
|
||||
barcode: str = None,
|
||||
):
|
||||
"""创建4×6枪头盒 (24个枪头)
|
||||
"""创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
|
||||
|
||||
注意:此函数已弃用,请使用 bottle_carriers.py 中的版本
|
||||
|
||||
Args:
|
||||
name: 枪头盒名称
|
||||
@@ -126,55 +128,11 @@ def BIOYOND_PolymerStation_TipBox(
|
||||
barcode: 条形码
|
||||
|
||||
Returns:
|
||||
TipBoxCarrier: 包含24个枪头孔位的枪头盒
|
||||
BottleCarrier: 包含24个枪头孔位的枪头盒载架
|
||||
"""
|
||||
from pylabrobot.resources import Container, Coordinate
|
||||
|
||||
# 创建枪头盒容器
|
||||
tip_box = Container(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category="tip_rack",
|
||||
model="BIOYOND_PolymerStation_TipBox_4x6",
|
||||
)
|
||||
|
||||
# 设置自定义属性
|
||||
tip_box.barcode = barcode
|
||||
tip_box.tip_count = 24 # 4行×6列
|
||||
tip_box.num_items_x = 6 # 6列
|
||||
tip_box.num_items_y = 4 # 4行
|
||||
|
||||
# 创建24个枪头孔位 (4行×6列)
|
||||
# 假设孔位间距为 9mm
|
||||
tip_spacing_x = 9.0 # 列间距
|
||||
tip_spacing_y = 9.0 # 行间距
|
||||
start_x = 14.38 # 第一个孔位的x偏移
|
||||
start_y = 11.24 # 第一个孔位的y偏移
|
||||
|
||||
for row in range(4): # A, B, C, D
|
||||
for col in range(6): # 1-6
|
||||
spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6
|
||||
x = start_x + col * tip_spacing_x
|
||||
y = start_y + row * tip_spacing_y
|
||||
|
||||
# 创建枪头孔位容器
|
||||
tip_spot = Container(
|
||||
name=spot_name,
|
||||
size_x=8.0, # 单个枪头孔位大小
|
||||
size_y=8.0,
|
||||
size_z=size_z - 10.0, # 略低于盒子高度
|
||||
category="tip_spot",
|
||||
)
|
||||
|
||||
# 添加到枪头盒
|
||||
tip_box.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x=x, y=y, z=0)
|
||||
)
|
||||
|
||||
return tip_box
|
||||
# 重定向到 bottle_carriers.py 中的实现
|
||||
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_PolymerStation_TipBox as TipBox_Carrier
|
||||
return TipBox_Carrier(name=name, size_x=size_x, size_y=size_y, size_z=size_z, barcode=barcode)
|
||||
|
||||
|
||||
def BIOYOND_PolymerStation_Flask(
|
||||
@@ -193,3 +151,20 @@ def BIOYOND_PolymerStation_Flask(
|
||||
barcode=barcode,
|
||||
model="BIOYOND_PolymerStation_Flask",
|
||||
)
|
||||
|
||||
def BIOYOND_PolymerStation_Measurement_Vial(
|
||||
name: str,
|
||||
diameter: float = 25.0,
|
||||
height: float = 60.0,
|
||||
max_volume: float = 20000.0, # 20mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建测量小瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="BIOYOND_PolymerStation_Measurement_Vial",
|
||||
)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from os import name
|
||||
from pylabrobot.resources import Deck, Coordinate, Rotation
|
||||
|
||||
from unilabos.resources.bioyond.warehouses import (
|
||||
from unilabos.resources.bioyond.YB_warehouses import (
|
||||
bioyond_warehouse_1x4x4,
|
||||
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05~D08)
|
||||
bioyond_warehouse_1x4x2,
|
||||
bioyond_warehouse_reagent_stack, # 新增:试剂堆栈 (A1-B4)
|
||||
bioyond_warehouse_liquid_and_lid_handling,
|
||||
bioyond_warehouse_1x2x2,
|
||||
bioyond_warehouse_2x2x1, # 新增:321和43窗口 (2行×2列)
|
||||
bioyond_warehouse_1x3x3,
|
||||
bioyond_warehouse_5x3x1, # 新增:手动传递窗仓库 (5行×3列)
|
||||
bioyond_warehouse_10x1x1,
|
||||
bioyond_warehouse_3x3x1,
|
||||
bioyond_warehouse_3x3x1_2,
|
||||
@@ -16,9 +18,12 @@ from unilabos.resources.bioyond.warehouses import (
|
||||
bioyond_warehouse_1x8x4,
|
||||
bioyond_warehouse_reagent_storage,
|
||||
# bioyond_warehouse_liquid_preparation,
|
||||
bioyond_warehouse_tipbox_storage, # 新增:Tip盒堆栈
|
||||
bioyond_warehouse_density_vial,
|
||||
)
|
||||
from unilabos.resources.bioyond.warehouses import (
|
||||
bioyond_warehouse_tipbox_storage_left, # 新增:Tip盒堆栈(左)
|
||||
bioyond_warehouse_tipbox_storage_right, # 新增:Tip盒堆栈(右)
|
||||
)
|
||||
|
||||
|
||||
class BIOYOND_PolymerReactionStation_Deck(Deck):
|
||||
@@ -45,24 +50,22 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
|
||||
"堆栈1右": bioyond_warehouse_1x4x4_right("堆栈1右"), # 右侧堆栈: A05~D08
|
||||
"站内试剂存放堆栈": bioyond_warehouse_reagent_storage("站内试剂存放堆栈"), # A01~A02
|
||||
# "移液站内10%分装液体准备仓库": bioyond_warehouse_liquid_preparation("移液站内10%分装液体准备仓库"), # A01~B04
|
||||
"站内Tip盒堆栈": bioyond_warehouse_tipbox_storage("站内Tip盒堆栈"), # A01~B03, 存放枪头盒.
|
||||
"站内Tip盒堆栈(左)": bioyond_warehouse_tipbox_storage_left("站内Tip盒堆栈(左)"), # A02~B03
|
||||
"站内Tip盒堆栈(右)": bioyond_warehouse_tipbox_storage_right("站内Tip盒堆栈(右)"), # A01~B01
|
||||
"测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01~B03
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置
|
||||
"堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置
|
||||
"站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0),
|
||||
# "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0),
|
||||
"站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0),
|
||||
"测量小瓶仓库(测密度)": Coordinate(922.0, 552.0, 0.0),
|
||||
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 左侧位置
|
||||
"堆栈1右": Coordinate(2350.0, 400.0, 0.0), # 右侧位置
|
||||
"站内试剂存放堆栈": Coordinate(640.0, 400.0, 0.0),
|
||||
"站内Tip盒堆栈(左)": Coordinate(300.0, 100.0, 0.0),
|
||||
"站内Tip盒堆栈(右)": Coordinate(2250.0, 100.0, 0.0), # 向右偏移 2 * item_dx (137.0)
|
||||
"测量小瓶仓库(测密度)": Coordinate(1000.0, 530.0, 0.0),
|
||||
}
|
||||
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
|
||||
self.warehouses["测量小瓶仓库(测密度)"].rotation = Rotation(z=270)
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
|
||||
class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -90,9 +93,9 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||
"溶液堆栈": bioyond_warehouse_1x4x4("溶液堆栈"), # 4行×4列 (A01-D04)
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"粉末堆栈": Coordinate(0.0, 450.0, 0.0),
|
||||
"试剂堆栈": Coordinate(1850.0, 200.0, 0.0),
|
||||
"溶液堆栈": Coordinate(2500.0, 450.0, 0.0),
|
||||
"粉末堆栈": Coordinate(-200.0, 400.0, 0.0),
|
||||
"试剂堆栈": Coordinate(1750.0, 160.0, 0.0),
|
||||
"溶液堆栈": Coordinate(2350.0, 400.0, 0.0),
|
||||
}
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
@@ -115,10 +118,10 @@ class BIOYOND_YB_Deck(Deck):
|
||||
def setup(self) -> None:
|
||||
# 添加仓库
|
||||
self.warehouses = {
|
||||
"321窗口": bioyond_warehouse_1x2x2("321窗口"),
|
||||
"43窗口": bioyond_warehouse_1x2x2("43窗口"),
|
||||
"手动传递窗左": bioyond_warehouse_1x3x3("手动传递窗左"),
|
||||
"手动传递窗右": bioyond_warehouse_1x3x3("手动传递窗右"),
|
||||
"321窗口": bioyond_warehouse_2x2x1("321窗口"), # 2行×2列
|
||||
"43窗口": bioyond_warehouse_2x2x1("43窗口"), # 2行×2列
|
||||
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03
|
||||
"手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03
|
||||
"加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"),
|
||||
"加样头堆栈右": bioyond_warehouse_10x1x1("加样头堆栈右"),
|
||||
|
||||
@@ -126,6 +129,7 @@ class BIOYOND_YB_Deck(Deck):
|
||||
"母液加样右": bioyond_warehouse_3x3x1_2("母液加样右"),
|
||||
"大瓶母液堆栈左": bioyond_warehouse_5x1x1("大瓶母液堆栈左"),
|
||||
"大瓶母液堆栈右": bioyond_warehouse_5x1x1("大瓶母液堆栈右"),
|
||||
"2号手套箱内部堆栈": bioyond_warehouse_3x3x1("2号手套箱内部堆栈"), # 新增:3行×3列 (A01-C03)
|
||||
}
|
||||
# warehouse 的位置
|
||||
self.warehouse_locations = {
|
||||
@@ -140,10 +144,12 @@ class BIOYOND_YB_Deck(Deck):
|
||||
"母液加样右": Coordinate(2152.0, 333.0, 0.0),
|
||||
"大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0),
|
||||
"大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0),
|
||||
"2号手套箱内部堆栈": Coordinate(-800, -500.0, 0.0), # 新增:位置需根据实际硬件调整
|
||||
}
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
def YB_Deck(name: str) -> Deck:
|
||||
by=BIOYOND_YB_Deck(name=name)
|
||||
by.setup()
|
||||
|
||||
@@ -46,48 +46,62 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
)
|
||||
|
||||
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
|
||||
"""创建测量小瓶仓库(测密度) A01~B03"""
|
||||
"""创建测量小瓶仓库(测密度) - 竖向排列2列3行
|
||||
布局(从下到上,从左到右):
|
||||
| A03 | B03 | ← 顶部
|
||||
| A02 | B02 | ← 中部
|
||||
| A01 | B01 | ← 底部
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列(01-03)
|
||||
num_items_y=2, # 2行(A-B)
|
||||
num_items_x=2, # 2列(A, B)
|
||||
num_items_y=3, # 3行(01-03,从下到上)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=40.0,
|
||||
item_dy=40.0,
|
||||
item_dx=40.0, # 列间距(A到B的横向距离)
|
||||
item_dy=40.0, # 行间距(01到02到03的竖向距离)
|
||||
item_dz=50.0,
|
||||
# 用更小的 resource_size 来表现 "小点的孔位"
|
||||
# ⭐ 竖向warehouse:槽位尺寸也是竖向的(小瓶已经是正方形,无需调整)
|
||||
resource_size_x=30.0,
|
||||
resource_size_y=30.0,
|
||||
resource_size_z=12.0,
|
||||
category="warehouse",
|
||||
col_offset=0,
|
||||
layout="row-major",
|
||||
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
|
||||
)
|
||||
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
"""创建BioYond站内试剂存放堆栈(A01~A02, 1行×2列)"""
|
||||
"""创建BioYond站内试剂存放堆栈 - 竖向排列1列2行
|
||||
布局(竖向,从下到上):
|
||||
| A02 | ← 顶部
|
||||
| A01 | ← 底部
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=2, # 2列(01-02)
|
||||
num_items_y=1, # 1行(A)
|
||||
num_items_x=1, # 1列
|
||||
num_items_y=2, # 2行(01-02,从下到上)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dx=96.0, # 列间距(这里只有1列,不重要)
|
||||
item_dy=137.0, # 行间距(A01到A02的竖向距离)
|
||||
item_dz=120.0,
|
||||
# ⭐ 竖向warehouse:交换槽位尺寸,使槽位框也是竖向的
|
||||
resource_size_x=86.0, # 原来的 resource_size_y
|
||||
resource_size_y=127.0, # 原来的 resource_size_x
|
||||
resource_size_z=25.0,
|
||||
category="warehouse",
|
||||
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
|
||||
)
|
||||
|
||||
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
|
||||
"""创建BioYond站内Tip盒堆栈(A01~B03),用于存放枪头盒"""
|
||||
def bioyond_warehouse_tipbox_storage_left(name: str) -> WareHouse:
|
||||
"""创建BioYond站内Tip盒堆栈左侧部分(A02~B03),2列2行"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列(01-03)
|
||||
num_items_x=2, # 2列
|
||||
num_items_y=2, # 2行(A-B)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
@@ -97,7 +111,25 @@ def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
col_offset=0,
|
||||
col_offset=1, # 从02开始: A02, A03
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_tipbox_storage_right(name: str) -> WareHouse:
|
||||
"""创建BioYond站内Tip盒堆栈右侧部分(A01~B01),1列2行"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1, # 1列
|
||||
num_items_y=2, # 2行(A-B)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="warehouse",
|
||||
col_offset=0, # 从01开始: A01
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
|
||||
@@ -779,9 +779,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
bottle = plr_material[number] = initialize_resource(
|
||||
{"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR
|
||||
)
|
||||
bottle.tracker.liquids = [
|
||||
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
|
||||
]
|
||||
# 只有具有 tracker 属性的容器才设置液体信息(如 Bottle, Well)
|
||||
# ResourceHolder 等不支持液体追踪的容器跳过
|
||||
if hasattr(bottle, "tracker"):
|
||||
bottle.tracker.liquids = [
|
||||
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
|
||||
]
|
||||
bottle.code = detail.get("code", "")
|
||||
logger.debug(f" └─ [子物料] {detail['name']} → {plr_material.name}[{number}] (类型:{typeName})")
|
||||
else:
|
||||
@@ -790,9 +793,11 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
# 只对有 capacity 属性的容器(液体容器)处理液体追踪
|
||||
if hasattr(plr_material, 'capacity'):
|
||||
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
|
||||
bottle.tracker.liquids = [
|
||||
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
|
||||
]
|
||||
# 确保 bottle 有 tracker 属性才设置液体信息
|
||||
if hasattr(bottle, "tracker"):
|
||||
bottle.tracker.liquids = [
|
||||
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
|
||||
]
|
||||
|
||||
plr_materials.append(plr_material)
|
||||
|
||||
@@ -801,42 +806,73 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
if not locations:
|
||||
logger.debug(f"[物料位置] {unique_name} 没有location信息,跳过warehouse放置")
|
||||
|
||||
# ⭐ 预先检查:如果物料的任何location在竖向warehouse中,提前交换尺寸
|
||||
# 这样可以避免多个location时尺寸不一致的问题
|
||||
needs_size_swap = False
|
||||
for loc in locations:
|
||||
wh_name_check = loc.get("whName")
|
||||
if wh_name_check in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||
needs_size_swap = True
|
||||
break
|
||||
|
||||
if needs_size_swap and hasattr(plr_material, 'size_x') and hasattr(plr_material, 'size_y'):
|
||||
original_x = plr_material.size_x
|
||||
original_y = plr_material.size_y
|
||||
plr_material.size_x = original_y
|
||||
plr_material.size_y = original_x
|
||||
logger.debug(f" 物料 {unique_name} 将放入竖向warehouse,预先交换尺寸: {original_x}×{original_y} → {plr_material.size_x}×{plr_material.size_y}")
|
||||
|
||||
for loc in locations:
|
||||
wh_name = loc.get("whName")
|
||||
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
|
||||
|
||||
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
|
||||
# 必须在warehouse映射之前先获取坐标,以便后续调整
|
||||
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
|
||||
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
|
||||
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
|
||||
|
||||
# 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右"
|
||||
# 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧
|
||||
# 根据列号(y)判断: 1-4映射到左侧, 5-8映射到右侧
|
||||
if wh_name == "堆栈1":
|
||||
x_val = loc.get("x", 1)
|
||||
if 1 <= x_val <= 4:
|
||||
if 1 <= y <= 4:
|
||||
wh_name = "堆栈1左"
|
||||
elif 5 <= x_val <= 8:
|
||||
elif 5 <= y <= 8:
|
||||
wh_name = "堆栈1右"
|
||||
y = y - 4 # 调整列号: 5-8映射到1-4
|
||||
else:
|
||||
logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右")
|
||||
logger.warning(f"物料 {material['name']} 的列号 y={y} 超出范围,无法映射到堆栈1左或堆栈1右")
|
||||
continue
|
||||
|
||||
# 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射
|
||||
if wh_name == "站内Tip盒堆栈":
|
||||
if y == 1:
|
||||
wh_name = "站内Tip盒堆栈(右)"
|
||||
elif y in [2, 3]:
|
||||
wh_name = "站内Tip盒堆栈(左)"
|
||||
y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列
|
||||
|
||||
if hasattr(deck, "warehouses") and wh_name in deck.warehouses:
|
||||
warehouse = deck.warehouses[wh_name]
|
||||
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
|
||||
|
||||
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
|
||||
# PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ...
|
||||
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
|
||||
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
|
||||
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
|
||||
# 特殊处理竖向warehouse(站内试剂存放堆栈、测量小瓶仓库)
|
||||
# 这些warehouse使用 vertical-col-major 布局
|
||||
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||
# vertical-col-major 布局的坐标映射:
|
||||
# - Bioyond的x(1=A,2=B)对应warehouse的列(col, x方向)
|
||||
# - Bioyond的y(1=01,2=02,3=03)对应warehouse的行(row, y方向),从下到上
|
||||
# vertical-col-major 中: row=0 对应底部,row=n-1 对应顶部
|
||||
# Bioyond y=1(01) 对应底部 → row=0, y=2(02) 对应中间 → row=1
|
||||
# 索引计算: idx = row * num_cols + col
|
||||
col_idx = x - 1 # Bioyond的x(A,B) → col索引(0,1)
|
||||
row_idx = y - 1 # Bioyond的y(01,02,03) → row索引(0,1,2)
|
||||
layer_idx = z - 1
|
||||
|
||||
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
|
||||
if wh_name == "堆栈1右":
|
||||
y = y - 4 # 将5-8映射到1-4
|
||||
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_y + col_idx
|
||||
logger.debug(f"🔍 竖向warehouse {wh_name}: Bioyond(x={x},y={y},z={z}) → warehouse(col={col_idx},row={row_idx},layer={layer_idx}) → idx={idx}, capacity={warehouse.capacity}")
|
||||
|
||||
# 特殊处理:对于1行×N列的横向warehouse(如站内试剂存放堆栈)
|
||||
# Bioyond的y坐标表示线性位置序号,而不是列号
|
||||
if warehouse.num_items_y == 1:
|
||||
# 1行warehouse: 直接用y作为线性索引
|
||||
idx = y - 1
|
||||
logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}")
|
||||
# 普通横向warehouse的处理
|
||||
else:
|
||||
# 多行warehouse: 根据 layout 使用不同的索引计算
|
||||
row_idx = x - 1 # x表示行: 转为0-based
|
||||
@@ -860,6 +896,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
|
||||
if 0 <= idx < warehouse.capacity:
|
||||
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
|
||||
# 物料尺寸已在放入warehouse前根据需要进行了交换
|
||||
warehouse[idx] = plr_material
|
||||
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
|
||||
else:
|
||||
@@ -1033,11 +1070,24 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
||||
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
|
||||
|
||||
# 🎯 处理物料默认参数和单位
|
||||
# 检查是否有该物料名称的默认参数配置
|
||||
# 优先级: typeId参数 > 物料名称参数 > 默认值
|
||||
default_unit = "个" # 默认单位
|
||||
material_parameters = {}
|
||||
|
||||
if material_name in material_params:
|
||||
# 1️⃣ 首先检查是否有 typeId 对应的参数配置(从 material_params 中获取,key 格式为 "type:<typeId>")
|
||||
type_params_key = f"type:{type_id}"
|
||||
if type_params_key in material_params:
|
||||
params_config = material_params[type_params_key].copy()
|
||||
|
||||
# 提取 unit 字段(如果有)
|
||||
if "unit" in params_config:
|
||||
default_unit = params_config.pop("unit") # 从参数中移除,放到外层
|
||||
|
||||
# 剩余的字段放入 Parameters
|
||||
material_parameters = params_config
|
||||
logger.debug(f" 🔧 [物料参数-按typeId] 为 typeId={type_id[:8]}... 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
# 2️⃣ 其次检查是否有该物料名称的默认参数配置
|
||||
elif material_name in material_params:
|
||||
params_config = material_params[material_name].copy()
|
||||
|
||||
# 提取 unit 字段(如果有)
|
||||
@@ -1046,7 +1096,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
||||
|
||||
# 剩余的字段放入 Parameters
|
||||
material_parameters = params_config
|
||||
logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
logger.debug(f" 🔧 [物料参数-按名称] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
|
||||
# 转换为 JSON 字符串
|
||||
parameters_json = json.dumps(material_parameters) if material_parameters else "{}"
|
||||
|
||||
@@ -29,7 +29,7 @@ class Bottle(Well):
|
||||
size_x: float = 0.0,
|
||||
size_y: float = 0.0,
|
||||
size_z: float = 0.0,
|
||||
barcode: Optional[str] = "",
|
||||
barcode: Optional[str] = None,
|
||||
category: str = "container",
|
||||
model: Optional[str] = None,
|
||||
**kwargs,
|
||||
@@ -50,13 +50,45 @@ class Bottle(Well):
|
||||
self.barcode = barcode
|
||||
|
||||
def serialize(self) -> dict:
|
||||
# Pylabrobot expects barcode to be an object with serialize(), but here it is a str.
|
||||
# We temporarily unset it to avoid AttributeError in super().serialize().
|
||||
_barcode = self.barcode
|
||||
self.barcode = None
|
||||
try:
|
||||
data = super().serialize()
|
||||
finally:
|
||||
self.barcode = _barcode
|
||||
|
||||
return {
|
||||
**super().serialize(),
|
||||
**data,
|
||||
"diameter": self.diameter,
|
||||
"height": self.height,
|
||||
"barcode": self.barcode,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal: bool = False):
|
||||
# Extract barcode before calling parent deserialize to avoid type error
|
||||
barcode_data = data.pop("barcode", None)
|
||||
|
||||
# Call parent deserialize
|
||||
instance = super(Bottle, cls).deserialize(data, allow_marshal=allow_marshal)
|
||||
|
||||
# Set barcode as string (not as Barcode object)
|
||||
if barcode_data:
|
||||
if isinstance(barcode_data, str):
|
||||
instance.barcode = barcode_data
|
||||
elif isinstance(barcode_data, dict):
|
||||
# If it's a dict (Barcode serialized format), extract the data field
|
||||
instance.barcode = barcode_data.get("data", "")
|
||||
else:
|
||||
instance.barcode = ""
|
||||
|
||||
# Set additional attributes
|
||||
instance.diameter = data.get("diameter", instance._size_x)
|
||||
instance.height = data.get("height", instance._size_z)
|
||||
|
||||
return instance
|
||||
|
||||
T = TypeVar("T", bound=ResourceHolder)
|
||||
|
||||
S = TypeVar("S", bound=ResourceHolder)
|
||||
@@ -79,7 +111,6 @@ class ItemizedCarrier(ResourcePLR):
|
||||
category: Optional[str] = "carrier",
|
||||
model: Optional[str] = None,
|
||||
invisible_slots: Optional[str] = None,
|
||||
content_type: Optional[List[str]] = ["bottle", "container", "tube", "bottle_carrier", "tip_rack"],
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
@@ -93,7 +124,6 @@ class ItemizedCarrier(ResourcePLR):
|
||||
self.num_items_x, self.num_items_y, self.num_items_z = num_items_x, num_items_y, num_items_z
|
||||
self.invisible_slots = [] if invisible_slots is None else invisible_slots
|
||||
self.layout = "z-y" if self.num_items_z > 1 and self.num_items_x == 1 else "x-z" if self.num_items_z > 1 and self.num_items_y == 1 else "x-y"
|
||||
self.content_type = content_type
|
||||
|
||||
if isinstance(sites, dict):
|
||||
sites = sites or {}
|
||||
@@ -421,7 +451,7 @@ class ItemizedCarrier(ResourcePLR):
|
||||
self[identifier] if isinstance(self[identifier], str) else None,
|
||||
"position": {"x": location.x, "y": location.y, "z": location.z},
|
||||
"size": self.child_size[identifier],
|
||||
"content_type": self.content_type
|
||||
"content_type": ["bottle", "container", "tube", "bottle_carrier", "tip_rack"]
|
||||
} for identifier, location in self.child_locations.items()]
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user