Merge dev branch: Add battery resources, bioyond_cell device registry, and fix file path resolution

This commit is contained in:
dijkstra402
2025-12-18 11:11:59 +08:00
306 changed files with 47518 additions and 4826 deletions

View File

@@ -0,0 +1,200 @@
# QOne NMR 用户指南
## 概述
Qone NMR 设备支持多字符串数据处理功能。该设备可以接收包含多个字符串的输入数据,并将每个字符串转换为独立的 TXT 文件,支持灵活的数据格式化和输出。
## 核心功能
- **多字符串处理**: 支持逗号分隔或换行分隔的多个字符串输入
- **自动文件生成**: 每个输入字符串生成一个对应的 TXT 文件
- **文件夹监督**: 自动监督指定目录,检测新内容生成
- **错误处理**: 完善的输入验证和错误处理机制
## 参数说明
### 输入参数
- **string** (str): 包含多个字符串的输入数据,支持格式:
- **逗号分隔**: `"字符串1, 字符串2, 字符串3"`
### 输出参数
- **return_info** (str): 处理结果信息,包含监督功能的执行结果
- **success** (bool): 处理是否成功
- **files_generated** (int): 生成的 TXT 文件数量
## 输入数据格式
### 支持的输入格式
1. **逗号分隔格式**:
```
"A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END"
```
```
### 数据项格式
每个字符串内的数据项应该用空格分隔,例如:
- `A 1 B 2 C 3 D 4 E 5 F 6 G 7 H 8 END`
- `Sample 001 Method A Volume 10.5 Temp 25.0`
## 输出文件说明
### 文件命名
生成的 TXT 文件将按照row_字符串顺序命名例如
- `row_1.txt`
- `row_2.txt`
### 文件格式
每个 TXT 文件包含对应字符串的格式化数据,格式为:
```
A 1
B 2
C 3
D 4
E 5
F 6
G 7
H 8
END
```
### 输出目录
默认输出目录为 `D:/setup/txt`,可以在 `device.json` 中配置 `output_dir` 参数。
## 文件夹监督功能
### 监督机制
设备在完成字符串到TXT文件的转换后会自动启动文件夹监督功能
- **监督目录**: 默认监督 `D:/Data/MyPC/Automation` 目录
- **检查间隔**: 每60秒检查一次新生成的.nmr文件
- **检测内容**: 新文件生成或现有文件大小变化
- **停止条件**: 连续三次文件大小没有变化,则检测完成
## 文件夹监督功能详细说明
Oxford NMR设备驱动集成了智能文件夹监督功能用于监测.nmr结果文件的生成完成状态。该功能通过监测文件大小变化来判断文件是否已完成写入。
### 工作机制
1. **文件大小监测**: 监督功能专门监测指定目录中新生成的.nmr文件的大小变化
2. **稳定性检测**: 当文件大小在连续多次检查中保持不变时,认为文件已完成写入
3. **批量处理支持**: 根据输入的.txt文件数量自动确定需要监测的.nmr文件数量
4. **实时反馈**: 提供详细的监测进度和文件状态信息
5. **自动停止**: 当所有期望的.nmr文件都达到稳定状态时监督功能自动停止start函数执行完毕
### 配置参数
可以通过`device.json`配置文件调整监督功能的行为:
```json
{
"config": {
"output_dir": "D:/setup/txt",
"monitor_dir": "D:\\Data\\MyPC\\Automation",
"stability_checks": 3,
"check_interval": 60
}
}
```
- `monitor_dir`: 监督的目录路径,默认为`D:\Data\MyPC\Automation`
- `stability_checks`: 文件大小稳定性检查次数默认为3次连续2次检查大小不变则认为文件完成
- `check_interval`: 检查间隔时间默认为60秒
### 监测逻辑
1. **初始状态记录**: 记录监督开始时目录中已存在的.nmr文件及其大小
2. **新文件检测**: 持续检测新生成的.nmr文件
3. **大小变化跟踪**: 为每个新文件维护大小变化历史记录
4. **稳定性判断**: 当文件大小在连续`stability_checks`次检查中保持不变且大小大于0时认为文件完成
5. **完成条件**: 当达到期望数量的.nmr文件都完成时监督功能结束
### 配置监督目录
可以在 `device.json` 中配置 `monitor_dir` 参数来指定监督目录:
```json
{
"config": {
"output_dir": "D:/setup/txt",
"monitor_dir": "D:/Data/MyPC/Automation"
}
}
```
## 使用示例
### 示例 1: 基本多字符串处理
```python
from unilabos.devices.Qone_nmr.Qone_nmr import Qone_nmr
# 创建设备实例
device = Qone_nmr(output_directory="D:/setup/txt")
# 输入多个字符串(逗号分隔)
input_data = "A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END"
# 处理数据
result = device.start(string=input_data)
print(f"处理结果: {result}")
# 输出: {'return_info': 'Oxford NMR处理完成: 已生成 3 个 txt 文件,保存在: ./output | 监督完成: 成功检测到 3 个.nmr文件已完成生成', 'success': True, 'files_generated': 3}
### 输出示例
当设备成功处理输入并完成文件监督后,会返回如下格式的结果:
```json
{
"return_info": "Oxford NMR处理完成: 已生成 3 个 txt 文件,保存在: D:/setup/txt | 监督完成: 成功检测到 3 个.nmr文件已完成生成start函数执行完毕",
"success": true,
"files_generated": 3
}
```
监督过程中的日志输出示例:
```
[INFO] 开始监督目录: D:/Data/MyPC/Automation检查间隔: 30秒期望.nmr文件数量: 3稳定性检查: 2次
[INFO] 初始状态: 0 个.nmr文件
[INFO] 检测到 3 个新.nmr文件还需要 0 个...
[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample1.nmr (当前: 1024 字节, 检查次数: 1/3)
[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample2.nmr (当前: 2048 字节, 检查次数: 1/3)
[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample3.nmr (当前: 1536 字节, 检查次数: 1/3)
[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample1.nmr (大小: 1024 字节)
[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample2.nmr (大小: 2048 字节)
[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample3.nmr (大小: 1536 字节)
[INFO] 所有期望的.nmr文件都已完成生成! 完成文件数: 3/3
[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample1.nmr (最终大小: 1024 字节)
[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample2.nmr (最终大小: 2048 字节)
[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample3.nmr (最终大小: 1536 字节)
[INFO] 停止文件夹监测,所有文件已完成
```
```
## 错误处理
设备具有完善的错误处理机制:
- **空输入**: 如果输入为空或 None返回错误信息
- **无效格式**: 如果输入格式不正确,返回相应错误
- **文件系统错误**: 如果输出目录不存在或无权限,返回错误信息
## 注意事项
1. **目录权限**: 确保监督目录具有读取权限,以便设备能够检测文件变化
2. **文件大小监测**: 监督功能现在基于文件大小变化来判断.nmr文件是否完成而不是简单的文件存在性检查
3. **稳定性检查**: 文件大小需要在连续多次检查中保持不变才被认为完成默认为3次检查
4. **自动停止**: 监督功能会在检测到期望数量的.nmr文件都达到稳定状态后自动停止避免无限循环
5. **配置灵活性**: 可以通过`device.json`调整稳定性检查次数和检查间隔,以适应不同的使用场景
6. **文件类型**: 监督功能专门针对.nmr文件忽略其他类型的文件变化
7. **批量处理**: 支持同时监测多个.nmr文件的完成状态适合批量处理场景

View File

@@ -0,0 +1,382 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Oxford NMR Device Driver for Uni-Lab OS
支持Oxford NMR设备的CSV字符串到TXT文件转换功能。
通过ROS2动作接口接收CSV字符串批量生成TXT文件到指定目录。
"""
import csv
import io
import logging
import os
import re
import time
from pathlib import Path
from typing import Dict, Any
class UniversalDriver:
"""Fallback UniversalDriver for standalone testing"""
def __init__(self):
self.success = False
class Qone_nmr(UniversalDriver):
"""Oxford NMR Device Driver
支持CSV字符串到TXT文件的批量转换功能。
"""
def __init__(self, **kwargs):
"""Initialize the Oxford NMR driver
Args:
**kwargs: Device-specific configuration parameters
- config: Configuration dictionary containing output_dir
"""
super().__init__()
# Device configuration
self.config = kwargs
config_dict = kwargs.get('config', {})
# 设置输出目录优先使用配置中的output_dir否则使用默认值
self.output_directory = "D:\\setup\\txt" # 默认输出目录
if config_dict and 'output_dir' in config_dict:
self.output_directory = config_dict['output_dir']
# 设置监督目录优先使用配置中的monitor_dir否则使用默认值
self.monitor_directory = "D:/Data/MyPC/Automation" # 默认监督目录
if config_dict and 'monitor_dir' in config_dict:
self.monitor_directory = config_dict['monitor_dir']
# 设置文件大小稳定性检查参数
self.stability_checks = 3 # 默认稳定性检查次数
if config_dict and 'stability_checks' in config_dict:
self.stability_checks = config_dict['stability_checks']
# 设置检查间隔时间
self.check_interval = 60 # 默认检查间隔(秒)
if config_dict and 'check_interval' in config_dict:
self.check_interval = config_dict['check_interval']
# 确保输出目录存在
os.makedirs(self.output_directory, exist_ok=True)
# ROS节点引用由框架设置
self._ros_node = None
# ROS2 action result properties
self.success = False
self.return_info = ""
# Setup logging
self.logger = logging.getLogger(f"Qone_nmr-{kwargs.get('id', 'unknown')}")
self.logger.info(f"Oxford NMR driver initialized with output directory: {self.output_directory}")
self.logger.info(f"Monitor directory set to: {self.monitor_directory}")
self.logger.info(f"Stability checks: {self.stability_checks}, Check interval: {self.check_interval}s")
def post_init(self, ros_node):
"""ROS节点初始化后的回调方法
Args:
ros_node: ROS节点实例
"""
self._ros_node = ros_node
ros_node.lab_logger().info(f"Oxford NMR设备初始化完成输出目录: {self.output_directory}")
def get_status(self) -> str:
"""获取设备状态
Returns:
str: 设备状态 (Idle|Offline|Error|Busy|Unknown)
"""
return "Idle" # NMR设备始终处于空闲状态等待处理请求
def strings_to_txt(self, string_list, output_dir=None, txt_encoding="utf-8"):
"""
将字符串列表写入多个 txt 文件
string_list: ["A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END", ...]
Args:
string_list: 字符串列表
output_dir: 输出目录如果未指定使用self.output_directory
txt_encoding: 文件编码
Returns:
int: 生成的文件数量
"""
# 使用指定的输出目录或默认目录
target_dir = output_dir if output_dir else self.output_directory
# 确保输出目录存在
os.makedirs(target_dir, exist_ok=True)
self.logger.info(f"开始生成文件到目录: {target_dir}")
for i, s in enumerate(string_list, start=1):
try:
# 去掉开头结尾的引号(如果有)
s = s.strip('"').strip("'")
# 拆分字符串
parts = s.split()
# 按两两一组重新排版为多行
txt_lines = []
for j in range(0, len(parts) - 1, 2):
txt_lines.append("{} {}".format(parts[j], parts[j+1]))
txt_lines.append("END")
txt_content = "\n".join(txt_lines)
# 生成文件名row_1.txt, row_2.txt, ...
file_name = "row_{}.txt".format(i)
out_path = os.path.join(target_dir, file_name)
with open(out_path, "w", encoding=txt_encoding) as f:
f.write(txt_content)
self.logger.info(f"成功生成文件: {file_name}")
except Exception as e:
self.logger.error(f"处理第{i}个字符串时出错: {str(e)}")
raise
return len(string_list) # 返回生成文件数量
def monitor_folder_for_new_content(self, monitor_dir=None, check_interval=60, expected_count=1, stability_checks=3):
"""监督指定文件夹中.nmr文件的大小变化当文件大小稳定时认为文件完成
Args:
monitor_dir (str): 要监督的目录路径如果未指定则使用self.monitor_directory
check_interval (int): 检查间隔时间默认60秒
expected_count (int): 期望生成的.nmr文件数量默认1个
stability_checks (int): 文件大小稳定性检查次数默认3次
Returns:
bool: 如果检测到期望数量的.nmr文件且大小稳定返回True否则返回False
"""
target_dir = monitor_dir if monitor_dir else self.monitor_directory
# 确保监督目录存在
if not os.path.exists(target_dir):
self.logger.warning(f"监督目录不存在: {target_dir}")
return False
self.logger.info(f"开始监督目录: {target_dir},检查间隔: {check_interval}秒,期望.nmr文件数量: {expected_count},稳定性检查: {stability_checks}")
# 记录初始的.nmr文件及其大小
initial_nmr_files = {}
try:
for root, dirs, files in os.walk(target_dir):
for file in files:
if file.lower().endswith('.nmr'):
file_path = os.path.join(root, file)
try:
file_size = os.path.getsize(file_path)
initial_nmr_files[file_path] = file_size
except OSError:
pass # 忽略无法访问的文件
except Exception as e:
self.logger.error(f"读取初始目录状态失败: {str(e)}")
return False
self.logger.info(f"初始状态: {len(initial_nmr_files)} 个.nmr文件")
# 跟踪新文件的大小变化历史
new_files_size_history = {}
completed_files = set()
# 开始监督循环
while True:
time.sleep(check_interval)
current_nmr_files = {}
try:
for root, dirs, files in os.walk(target_dir):
for file in files:
if file.lower().endswith('.nmr'):
file_path = os.path.join(root, file)
try:
file_size = os.path.getsize(file_path)
current_nmr_files[file_path] = file_size
except OSError:
pass
# 找出新生成的.nmr文件
new_nmr_files = set(current_nmr_files.keys()) - set(initial_nmr_files.keys())
if len(new_nmr_files) < expected_count:
self.logger.info(f"检测到 {len(new_nmr_files)} 个新.nmr文件还需要 {expected_count - len(new_nmr_files)} 个...")
continue
# 检查新文件的大小稳定性
for file_path in new_nmr_files:
if file_path in completed_files:
continue
current_size = current_nmr_files.get(file_path, 0)
# 初始化文件大小历史记录
if file_path not in new_files_size_history:
new_files_size_history[file_path] = []
# 记录当前大小
new_files_size_history[file_path].append(current_size)
# 保持历史记录长度不超过稳定性检查次数
if len(new_files_size_history[file_path]) > stability_checks:
new_files_size_history[file_path] = new_files_size_history[file_path][-stability_checks:]
# 检查大小是否稳定
size_history = new_files_size_history[file_path]
if len(size_history) >= stability_checks:
# 检查最近几次的大小是否相同且不为0
if len(set(size_history[-stability_checks:])) == 1 and size_history[-1] > 0:
self.logger.info(f"文件大小已稳定: {file_path} (大小: {current_size} 字节)")
completed_files.add(file_path)
else:
self.logger.debug(f"文件大小仍在变化: {file_path} (当前: {current_size} 字节, 历史: {size_history[-3:]})")
else:
self.logger.debug(f"文件大小监测中: {file_path} (当前: {current_size} 字节, 检查次数: {len(size_history)}/{stability_checks})")
# 检查是否所有期望的文件都已完成
if len(completed_files) >= expected_count:
self.logger.info(f"所有期望的.nmr文件都已完成生成! 完成文件数: {len(completed_files)}/{expected_count}")
for completed_file in list(completed_files)[:expected_count]:
final_size = current_nmr_files.get(completed_file, 0)
self.logger.info(f"完成的.nmr文件: {completed_file} (最终大小: {final_size} 字节)")
self.logger.info("停止文件夹监测,所有文件已完成")
return True
else:
self.logger.info(f"已完成 {len(completed_files)} 个文件,还需要 {expected_count - len(completed_files)} 个文件完成...")
except Exception as e:
self.logger.error(f"监督过程中出错: {str(e)}")
return False
def start(self, string: str = None) -> dict:
"""使用字符串列表启动TXT文件生成支持ROS2动作调用
Args:
string (str): 包含多个字符串的输入数据,支持两种格式:
1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30"
2. 换行分隔:如 "A 1 B 2 C 3\nX 10 Y 20 Z 30"
Returns:
dict: ROS2动作结果格式 {"return_info": str, "success": bool, "files_generated": int}
"""
try:
if string is None or string.strip() == "":
error_msg = "未提供字符串参数或参数为空"
self.logger.error(error_msg)
return {"return_info": error_msg, "success": False, "files_generated": 0}
self.logger.info(f"开始处理字符串数据,长度: {len(string)} 字符")
# 支持两种分隔方式:逗号分隔或换行分隔
string_list = []
# 首先尝试逗号分隔
if ',' in string:
string_list = [item.strip() for item in string.split(',') if item.strip()]
else:
# 如果没有逗号,则按换行分隔
string_list = [line.strip() for line in string.strip().split('\n') if line.strip()]
if not string_list:
error_msg = "输入字符串解析后为空"
self.logger.error(error_msg)
return {"return_info": error_msg, "success": False, "files_generated": 0}
# 确保输出目录存在
os.makedirs(self.output_directory, exist_ok=True)
# 使用strings_to_txt函数生成TXT文件
file_count = self.strings_to_txt(
string_list=string_list,
output_dir=self.output_directory,
txt_encoding='utf-8'
)
success_msg = f"Oxford NMR处理完成: 已生成 {file_count} 个 txt 文件,保存在: {self.output_directory}"
self.logger.info(success_msg)
# 在string转txt完成后启动文件夹监督功能
self.logger.info(f"开始启动文件夹监督功能,期望生成 {file_count} 个.nmr文件...")
monitor_result = self.monitor_folder_for_new_content(
expected_count=file_count,
check_interval=self.check_interval,
stability_checks=self.stability_checks
)
if monitor_result:
success_msg += f" | 监督完成: 成功检测到 {file_count} 个.nmr文件已完成生成start函数执行完毕"
else:
success_msg += f" | 监督结束: 监督过程中断或失败start函数执行完毕"
return {"return_info": success_msg, "success": True, "files_generated": file_count}
except Exception as e:
error_msg = f"字符串处理失败: {str(e)}"
self.logger.error(error_msg)
return {"return_info": error_msg, "success": False, "files_generated": 0}
def test_qone_nmr():
"""测试Qone_nmr设备的字符串处理功能"""
try:
# 配置日志输出
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("Qone_nmr_test")
logger.info("开始测试Qone_nmr设备...")
# 创建设备实例,使用正确的配置格式
device = Qone_nmr(config={'output_dir': "D:\\setup\\txt"})
logger.info(f"设备初始化完成,输出目录: {device.output_directory}")
# 测试数据:多个字符串,逗号分隔
test_strings = "A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END"
logger.info(f"测试输入: {test_strings}")
# 确保输出目录存在
if not os.path.exists(device.output_directory):
os.makedirs(device.output_directory, exist_ok=True)
logger.info(f"创建输出目录: {device.output_directory}")
# 调用start方法
result = device.start(string=test_strings)
logger.info(f"处理结果: {result}")
# 显示生成的文件内容
if result.get('success', False):
output_dir = device.output_directory
if os.path.exists(output_dir):
txt_files = [f for f in os.listdir(output_dir) if f.endswith('.txt')]
logger.info(f"生成的文件数量: {len(txt_files)}")
for i, filename in enumerate(txt_files[:2]): # 只显示前2个文件
filepath = os.path.join(output_dir, filename)
logger.info(f"文件 {i+1}: {filename}")
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
logger.info(f"内容:\n{content}")
logger.info("测试完成!")
return result
except Exception as e:
logger.error(f"测试过程中出现错误: {str(e)}")
import traceback
traceback.print_exc()
return {"return_info": f"测试失败: {str(e)}", "success": False, "files_generated": 0}
if __name__ == "__main__":
test_qone_nmr()

View File

@@ -0,0 +1,25 @@
{
"nodes": [
{
"id": "Qone_nmr_device",
"name": "Qone_NMR_Device",
"parent": null,
"type": "device",
"class": "Qone_nmr",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"output_dir": "D:\\setup\\txt",
"monitor_dir": "D:\\Data\\MyPC\\Automation",
"stability_checks": 3,
"check_interval": 60
},
"data": {},
"children": []
}
],
"links": []
}

View File

@@ -0,0 +1,4 @@
USERNAME,SLOT,EXPNAME,FILE,SOLVENT,TEMPLATE,TITLE
User,SLOT,Name,No.,SOLVENT,Experiment,TITLE
用户名,进样器孔位,实验任务的名字,保存文件的名字,溶剂(按照实验的要求),模板(按照实验的要求,指定测试的元素),标题
admin,18,11LiDFOB,LiDFOB-11B,DMSO,B11,11LiDFOB_400MHz
1 USERNAME SLOT EXPNAME FILE SOLVENT TEMPLATE TITLE
2 User SLOT Name No. SOLVENT Experiment TITLE
3 用户名 进样器孔位 实验任务的名字 保存文件的名字 溶剂(按照实验的要求) 模板(按照实验的要求,指定测试的元素) 标题
4 admin 18 11LiDFOB LiDFOB-11B DMSO B11 11LiDFOB_400MHz

View File

@@ -0,0 +1,61 @@
import serial
import time
ser = serial.Serial(
port="COM18",
baudrate=9600,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=15,
def send_cmd(cmd: str, wait: float = 1.0) -> str:
"""向 Cytomat 发送一行命令并打印/返回响应。"""
print(f">>> {cmd}")
ser.write((cmd + "\r").encode("ascii"))
time.sleep(wait)
resp = ser.read_all().decode("ascii", errors="ignore").strip()
print(f"<<< {resp or '<no response>'}")
return resp
def initialize():
"""设备初始化 (ll:in)。"""
return send_cmd("ll:in")
def wp_to_storage(pos: int):
"""WP → 库位。pos: 19999 绝对地址。"""
return send_cmd(f"mv:ws {pos:04d}")
def storage_to_tfs(stacker: int, level: int):
"""库位 → TFS1。"""
return send_cmd(f"mv:st {stacker:02d} {level:02d}")
def get_basic_state():
"""查询 Basic State Register。"""
return send_cmd("ch:bs")
def set_pitch(stacker: int, pitch_mm: int):
"""设置单个 stacker 的层间距mm"""
return send_cmd(f"se:cs {stacker:02d} {pitch_mm}")
def tfs_to_storage(stacker: int, level: int):
"""TFS1 → 库位。"""
return send_cmd(f"mv:ts {stacker:02d} {level:02d}")
# ---------- 示例工作流 ----------
if __name__ == "__main__":
try:
if not ser.is_open:
ser.open()
initialize()
wp_to_storage(10)
storage_to_tfs(17, 3)
get_basic_state()
tfs_to_storage(7, 5)
except Exception as exc:
print("Error:", exc)
finally:
ser.close()
print("Done.")

View File

@@ -405,9 +405,19 @@ class RunningResultChecker(DriverChecker):
for i in range(self.driver._finished, temp):
sample_id = self.driver._get_resource_sample_id(self.driver._wf_name, i) # 从0开始计数
pdf, txt = self.driver.get_data_file(i + 1)
device_id = self.driver.device_id if hasattr(self.driver, "device_id") else "default"
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example", device_id=device_id)
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result", device_id=device_id)
# 使用新的OSS上传接口传入driver_name和exp_type
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
if pdf_result["success"]:
print(f"PDF上传成功: {pdf_result['oss_path']}")
else:
print(f"PDF上传失败: {pdf_result['original_path']}")
if txt_result["success"]:
print(f"TXT上传成功: {txt_result['oss_path']}")
else:
print(f"TXT上传失败: {txt_result['original_path']}")
# self.driver.extract_data_from_txt()
except Exception as ex:
self.driver._finished = 0
@@ -456,8 +466,12 @@ if __name__ == "__main__":
}
sample_id = obj._get_resource_sample_id("test", 0)
pdf, txt = obj.get_data_file("1", after_time=datetime(2024, 11, 6, 19, 3, 6))
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example")
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result")
# 使用新的OSS上传接口传入driver_name和exp_type
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
print(f"PDF上传结果: {pdf_result}")
print(f"TXT上传结果: {txt_result}")
# driver = HPLCDriver()
# for i in range(10000):
# print({k: v for k, v in driver._device_status.items() if isinstance(v, str)})

View File

@@ -0,0 +1,138 @@
import os
import time
import json
import logging
from xyz_stepper_driver import ModbusRTUTransport, ModbusClient, XYZStepperController, MotorStatus
# ========== 日志配置 ==========
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("XYZ_Debug")
def create_controller(port: str = "/dev/ttyUSB1", baudrate: int = 115200) -> XYZStepperController:
"""
初始化通信层与三轴控制器
"""
logger.info(f"🔧 初始化控制器: {port} @ {baudrate}bps")
transport = ModbusRTUTransport(port=port, baudrate=baudrate)
transport.open()
client = ModbusClient(transport)
return XYZStepperController(client=client, port=port, baudrate=baudrate)
def load_existing_soft_zero(ctrl: XYZStepperController, path: str = "work_origin.json") -> bool:
"""
如果已存在软零点文件则加载,否则返回 False
"""
if not os.path.exists(path):
logger.warning("⚠ 未找到已有软零点文件,将等待人工定义新零点。")
return False
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
origin = data.get("work_origin_steps", {})
ctrl.work_origin_steps = origin
ctrl.is_homed = True
logger.info(f"✔ 已加载软零点文件:{path}")
logger.info(f"当前软零点步数: {origin}")
return True
except Exception as e:
logger.error(f"读取软零点文件失败: {e}")
return False
def test_enable_axis(ctrl: XYZStepperController):
"""
依次使能 X / Y / Z 三轴
"""
logger.info("=== 测试各轴使能 ===")
for axis in ["X", "Y", "Z"]:
try:
result = ctrl.enable(axis, True)
if result:
vals = ctrl.get_status(axis)
st = MotorStatus(vals[3])
logger.info(f"{axis} 轴使能成功,当前状态: {st.name}")
else:
logger.error(f"{axis} 轴使能失败")
except Exception as e:
logger.error(f"{axis} 轴使能异常: {e}")
time.sleep(0.5)
def test_status_read(ctrl: XYZStepperController):
"""
读取各轴当前状态(调试)
"""
logger.info("=== 当前各轴状态 ===")
for axis in ["X", "Y", "Z"]:
try:
vals = ctrl.get_status(axis)
st = MotorStatus(vals[3])
logger.info(
f"{axis}: steps={vals[0]}, speed={vals[1]}, "
f"current={vals[2]}, status={st.name}"
)
except Exception as e:
logger.error(f"获取 {axis} 状态失败: {e}")
time.sleep(0.2)
def redefine_soft_zero(ctrl: XYZStepperController):
"""
手动重新定义软零点
"""
logger.info("=== ⚙️ 重新定义软零点 ===")
ctrl.define_current_as_zero("work_origin.json")
logger.info("✅ 新软零点已写入 work_origin.json")
def test_soft_zero_move(ctrl: XYZStepperController):
"""
以软零点为基准执行三轴运动测试
"""
logger.info("=== 测试软零点相对运动 ===")
ctrl.move_xyz_work(x=100.0, y=100.0, z=40.0, speed=100, acc=800)
for axis in ["X", "Y", "Z"]:
ctrl.wait_complete(axis)
test_status_read(ctrl)
logger.info("✅ 软零点运动测试完成")
def main():
ctrl = create_controller(port="/dev/ttyUSB1", baudrate=115200)
try:
test_enable_axis(ctrl)
test_status_read(ctrl)
# === 初始化或加载软零点 ===
loaded = load_existing_soft_zero(ctrl)
if not loaded:
logger.info("👣 首次运行,定义软零点并保存。")
ctrl.define_current_as_zero("work_origin.json")
# === 软零点回归动作 ===
ctrl.return_to_work_origin()
# === 可选软零点运动测试 ===
# test_soft_zero_move(ctrl)
except KeyboardInterrupt:
logger.info("🛑 手动中断退出")
except Exception as e:
logger.exception(f"❌ 调试出错: {e}")
finally:
if hasattr(ctrl.client, "transport"):
ctrl.client.transport.close()
logger.info("串口已安全关闭 ✅")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,58 @@
import logging
from xyz_stepper_driver import (
ModbusRTUTransport,
ModbusClient,
XYZStepperController,
MotorAxis,
)
logger = logging.getLogger("XYZStepperCommTest")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
def test_xyz_stepper_comm():
"""仅测试 Modbus 通信是否正常(并输出寄存器数据,不做电机运动)"""
port = "/dev/ttyUSB1"
baudrate = 115200
timeout = 1.2 # 略长避免响应被截断
logger.info(f"尝试连接 Modbus 设备 {port} ...")
transport = ModbusRTUTransport(port, baudrate=baudrate, timeout=timeout)
transport.open()
client = ModbusClient(transport)
ctrl = XYZStepperController(client)
try:
logger.info("✅ 串口已打开,开始读取三个轴状态(打印寄存器内容) ...")
for axis in [MotorAxis.X, MotorAxis.Y, MotorAxis.Z]:
addr = ctrl.axis_addr[axis]
try:
# # 在 get_status 前打印原始寄存器内容
# regs = client.read_registers(addr, ctrl.REG_STATUS, 6)
# hex_regs = [f"0x{val:04X}" for val in regs]
# logger.info(f"[{axis.name}] 原始寄存器 ({len(regs)} 个): {regs} -> {hex_regs}")
# 调用 get_status() 正常解析
status = ctrl.get_status(axis)
logger.info(
f"[{axis.name}] ✅ 通信正常: steps={status.steps}, speed={status.speed}, "
f"current={status.current}, status={status.status.name}"
)
except Exception as e_axis:
logger.error(f"[{axis.name}] ❌ 通信失败: {e_axis}")
except Exception as e:
logger.error(f"❌ 通讯测试失败: {e}")
finally:
transport.close()
logger.info("🔌 串口已关闭")
if __name__ == "__main__":
test_xyz_stepper_comm()

View File

@@ -0,0 +1,8 @@
{
"work_origin_steps": {
"x": 11799,
"y": 11476,
"z": 3312
},
"timestamp": "2025-11-04T15:31:09.802155"
}

View File

@@ -0,0 +1,336 @@
"""
XYZ 三轴步进电机驱动(统一字符串参数版)
基于 Modbus RTU 协议
Author: Xiuyu Chen (Modified by Assistant)
"""
import serial # type: ignore
import struct
import time
import logging
from enum import Enum
from dataclasses import dataclass
from typing import Optional, List, Dict
# ========== 日志配置 ==========
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("XYZStepper")
# ========== 层 1Modbus RTU ==========
class ModbusException(Exception):
pass
class ModbusRTUTransport:
"""底层串口通信层"""
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.2):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.ser: Optional[serial.Serial] = None
def open(self):
try:
self.ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.02,
write_timeout=0.5,
)
logger.info(f"[RTU] 串口连接成功: {self.port}")
except Exception as e:
raise ModbusException(f"无法打开串口 {self.port}: {e}")
def close(self):
if self.ser and self.ser.is_open:
self.ser.close()
logger.info("[RTU] 串口已关闭")
def send(self, frame: bytes):
if not self.ser or not self.ser.is_open:
raise ModbusException("串口未连接")
self.ser.reset_input_buffer()
self.ser.write(frame)
self.ser.flush()
logger.debug(f"[TX] {frame.hex(' ').upper()}")
def receive(self, expected_len: int) -> bytes:
if not self.ser or not self.ser.is_open:
raise ModbusException("串口未连接")
start = time.time()
buf = bytearray()
while len(buf) < expected_len and (time.time() - start) < self.timeout:
chunk = self.ser.read(expected_len - len(buf))
if chunk:
buf.extend(chunk)
else:
time.sleep(0.01)
return bytes(buf)
# ========== 层 2Modbus 协议 ==========
class ModbusFunction(Enum):
READ_HOLDING_REGISTERS = 0x03
WRITE_SINGLE_REGISTER = 0x06
WRITE_MULTIPLE_REGISTERS = 0x10
class ModbusClient:
"""Modbus RTU 客户端"""
def __init__(self, transport: ModbusRTUTransport):
self.transport = transport
@staticmethod
def calc_crc(data: bytes) -> bytes:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return struct.pack("<H", crc)
def send_request(self, addr: int, func: int, payload: bytes) -> bytes:
frame = bytes([addr, func]) + payload
full = frame + self.calc_crc(frame)
self.transport.send(full)
time.sleep(0.01)
resp = self.transport.ser.read(256)
if not resp:
raise ModbusException("未收到响应")
start = resp.find(bytes([addr, func]))
if start > 0:
resp = resp[start:]
if len(resp) < 5:
raise ModbusException(f"响应长度不足: {resp.hex(' ').upper()}")
if self.calc_crc(resp[:-2]) != resp[-2:]:
raise ModbusException("CRC 校验失败")
return resp
def read_registers(self, addr: int, start: int, count: int) -> List[int]:
payload = struct.pack(">HH", start, count)
resp = self.send_request(addr, ModbusFunction.READ_HOLDING_REGISTERS.value, payload)
byte_count = resp[2]
regs = [struct.unpack(">H", resp[3 + i:5 + i])[0] for i in range(0, byte_count, 2)]
return regs
def write_single_register(self, addr: int, reg: int, val: int) -> bool:
payload = struct.pack(">HH", reg, val)
resp = self.send_request(addr, ModbusFunction.WRITE_SINGLE_REGISTER.value, payload)
return resp[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
def write_multiple_registers(self, addr: int, start: int, values: List[int]) -> bool:
byte_count = len(values) * 2
payload = struct.pack(">HHB", start, len(values), byte_count)
payload += b"".join(struct.pack(">H", v & 0xFFFF) for v in values)
resp = self.send_request(addr, ModbusFunction.WRITE_MULTIPLE_REGISTERS.value, payload)
return resp[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
# ========== 层 3业务逻辑 ==========
class MotorAxis(Enum):
X = 1
Y = 2
Z = 3
class MotorStatus(Enum):
STANDBY = 0
RUNNING = 1
COLLISION_STOP = 2
FORWARD_LIMIT_STOP = 3
REVERSE_LIMIT_STOP = 4
@dataclass
class MotorPosition:
steps: int
speed: int
current: int
status: MotorStatus
class XYZStepperController:
"""XYZ 三轴步进控制器(字符串接口版)"""
STEPS_PER_REV = 16384
LEAD_MM_X, LEAD_MM_Y, LEAD_MM_Z = 80.0, 80.0, 5.0
STEPS_PER_MM_X = STEPS_PER_REV / LEAD_MM_X
STEPS_PER_MM_Y = STEPS_PER_REV / LEAD_MM_Y
STEPS_PER_MM_Z = STEPS_PER_REV / LEAD_MM_Z
REG_STATUS, REG_POS_HIGH, REG_POS_LOW = 0x00, 0x01, 0x02
REG_ACTUAL_SPEED, REG_CURRENT, REG_ENABLE = 0x03, 0x05, 0x06
REG_ZERO_CMD, REG_TARGET_HIGH, REG_TARGET_LOW = 0x0F, 0x10, 0x11
REG_SPEED, REG_ACCEL, REG_PRECISION, REG_START = 0x13, 0x14, 0x15, 0x16
REG_COMMAND = 0x60
def __init__(self, client: Optional[ModbusClient] = None,
port="/dev/ttyUSB0", baudrate=115200,
origin_path="unilabos/devices/laiyu_liquid_test/work_origin.json"):
if client is None:
transport = ModbusRTUTransport(port, baudrate)
transport.open()
self.client = ModbusClient(transport)
else:
self.client = client
self.axis_addr = {MotorAxis.X: 1, MotorAxis.Y: 2, MotorAxis.Z: 3}
self.work_origin_steps = {"x": 0, "y": 0, "z": 0}
self.is_homed = False
self._load_work_origin(origin_path)
# ========== 基础工具 ==========
@staticmethod
def s16(v: int) -> int:
return v - 0x10000 if v & 0x8000 else v
@staticmethod
def s32(h: int, l: int) -> int:
v = (h << 16) | l
return v - 0x100000000 if v & 0x80000000 else v
@classmethod
def mm_to_steps(cls, axis: str, mm: float = 0.0) -> int:
axis = axis.upper()
if axis == "X":
return int(mm * cls.STEPS_PER_MM_X)
elif axis == "Y":
return int(mm * cls.STEPS_PER_MM_Y)
elif axis == "Z":
return int(mm * cls.STEPS_PER_MM_Z)
raise ValueError(f"未知轴: {axis}")
@classmethod
def steps_to_mm(cls, axis: str, steps: int) -> float:
axis = axis.upper()
if axis == "X":
return steps / cls.STEPS_PER_MM_X
elif axis == "Y":
return steps / cls.STEPS_PER_MM_Y
elif axis == "Z":
return steps / cls.STEPS_PER_MM_Z
raise ValueError(f"未知轴: {axis}")
# ========== 状态与控制 ==========
def get_status(self, axis: str = "Z") -> list:
"""返回简化数组格式: [steps, speed, current, status_value]"""
if isinstance(axis, MotorAxis):
axis_enum = axis
elif isinstance(axis, str):
axis_enum = MotorAxis[axis.upper()]
else:
raise TypeError("axis 参数必须为 str 或 MotorAxis")
vals = self.client.read_registers(self.axis_addr[axis_enum], self.REG_STATUS, 6)
return [
self.s32(vals[1], vals[2]),
self.s16(vals[3]),
vals[4],
int(MotorStatus(vals[0]).value)
]
def enable(self, axis: str, state: bool) -> bool:
a = MotorAxis[axis.upper()]
return self.client.write_single_register(self.axis_addr[a], self.REG_ENABLE, 1 if state else 0)
def wait_complete(self, axis: str, timeout=30.0) -> bool:
a = axis.upper()
start = time.time()
while time.time() - start < timeout:
vals = self.get_status(a)
st = MotorStatus(vals[3]) # 第4个元素是状态值
if st == MotorStatus.STANDBY:
return True
if st in (MotorStatus.COLLISION_STOP, MotorStatus.FORWARD_LIMIT_STOP, MotorStatus.REVERSE_LIMIT_STOP):
logger.warning(f"{a} 轴异常停止: {st.name}")
return False
time.sleep(0.1)
logger.warning(f"{a} 轴运动超时")
return False
# ========== 控制命令 ==========
def move_to(self, axis: str, steps: int, speed: int = 2000, acc: int = 500, precision: int = 50) -> bool:
a = MotorAxis[axis.upper()]
addr = self.axis_addr[a]
hi, lo = (steps >> 16) & 0xFFFF, steps & 0xFFFF
values = [hi, lo, speed, acc, precision]
ok = self.client.write_multiple_registers(addr, self.REG_TARGET_HIGH, values)
if ok:
self.client.write_single_register(addr, self.REG_START, 1)
return ok
def move_xyz_work(self, x: float = 0.0, y: float = 0.0, z: float = 0.0, speed: int = 100, acc: int = 1500):
logger.info("🧭 执行安全多轴运动Z→XY→Z")
if z is not None:
safe_z = self._to_machine_steps("Z", 0.0)
self.move_to("Z", safe_z, speed, acc)
self.wait_complete("Z")
if x is not None or y is not None:
if x is not None:
self.move_to("X", self._to_machine_steps("X", x), speed, acc)
if y is not None:
self.move_to("Y", self._to_machine_steps("Y", y), speed, acc)
if x is not None:
self.wait_complete("X")
if y is not None:
self.wait_complete("Y")
if z is not None:
self.move_to("Z", self._to_machine_steps("Z", z), speed, acc)
self.wait_complete("Z")
logger.info("✅ 多轴顺序运动完成")
# ========== 坐标与零点 ==========
def _to_machine_steps(self, axis: str, mm: float) -> int:
base = self.work_origin_steps.get(axis.lower(), 0)
return base + self.mm_to_steps(axis, mm)
def define_current_as_zero(self, save_path="work_origin.json"):
import json
from datetime import datetime
origin = {}
for axis in ["X", "Y", "Z"]:
vals = self.get_status(axis)
origin[axis.lower()] = int(vals[0]) # 第1个是步数
with open(save_path, "w", encoding="utf-8") as f:
json.dump({"work_origin_steps": origin, "timestamp": datetime.now().isoformat()}, f, indent=2)
self.work_origin_steps = origin
self.is_homed = True
logger.info(f"✅ 零点已定义并保存至 {save_path}")
def _load_work_origin(self, path: str) -> bool:
import json, os
if not os.path.exists(path):
logger.warning("⚠️ 未找到软零点文件")
return False
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
self.work_origin_steps = data.get("work_origin_steps", {"x": 0, "y": 0, "z": 0})
self.is_homed = True
logger.info(f"📂 软零点已加载: {self.work_origin_steps}")
return True
def return_to_work_origin(self, speed: int = 200, acc: int = 800):
logger.info("🏁 回工件软零点")
self.move_to("Z", self._to_machine_steps("Z", 0.0), speed, acc)
self.wait_complete("Z")
self.move_to("X", self.work_origin_steps.get("x", 0), speed, acc)
self.move_to("Y", self.work_origin_steps.get("y", 0), speed, acc)
self.wait_complete("X")
self.wait_complete("Y")
self.move_to("Z", self.work_origin_steps.get("z", 0), speed, acc)
self.wait_complete("Z")
logger.info("🎯 回软零点完成 ✅")

View File

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

View File

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

View File

@@ -0,0 +1,385 @@
import json
from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import (
LiquidHandlerBackend,
)
from pylabrobot.liquid_handling.standard import (
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
)
from pylabrobot.resources import Resource, Tip
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
import time
from rclpy.action import ActionClient
from unilabos_msgs.action import SendCmd
import re
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
"""Chatter box backend for device-free testing. Prints out all operations."""
_pip_length = 5
_vol_length = 8
_resource_length = 20
_offset_length = 16
_flow_rate_length = 10
_blowout_length = 10
_lld_z_length = 10
_kwargs_length = 15
_tip_type_length = 12
_max_volume_length = 16
_fitting_depth_length = 20
_tip_length_length = 16
# _pickup_method_length = 20
_filter_length = 10
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
"""Initialize a chatter box backend."""
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
# rclpy.init()
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
self.hardware_interface = PipetteController(port=port)
async def setup(self):
# self.joint_state_publisher = JointStatePublisher()
# self.hardware_interface.xyz_controller.connect_device()
# self.hardware_interface.xyz_controller.home_all_axes()
await super().setup()
self.hardware_interface.connect()
self.hardware_interface.initialize()
print("Setting up the liquid handler.")
async def stop(self):
print("Stopping the liquid handler.")
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
def pipette_aspirate(self, volume: float, flow_rate: float):
self.hardware_interface.pipette.set_max_speed(flow_rate)
res = self.hardware_interface.pipette.aspirate(volume=volume)
if not res:
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
return
self.hardware_interface.current_volume += volume
def pipette_dispense(self, volume: float, flow_rate: float):
self.hardware_interface.pipette.set_max_speed(flow_rate)
res = self.hardware_interface.pipette.dispense(volume=volume)
if not res:
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
return
self.hardware_interface.current_volume -= volume
@property
def num_channels(self) -> int:
return self._num_channels
async def assigned_resource_callback(self, resource: Resource):
print(f"Resource {resource.name} was assigned to the liquid handler.")
async def unassigned_resource_callback(self, name: str):
print(f"Resource {name} was unassigned from the liquid handler.")
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
print("Picking up tips:")
# print(ops.tip)
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
# print(op.resource.get_absolute_location())
self.tip_length = ops[0].tip.total_tip_length
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("已有枪头,无需重复拾取")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=100)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
# goback()
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
print("Dropping tips:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
# print(x, y, z)
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
print("无枪头,无需丢弃")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
self.hardware_interface.eject_tip
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
print("Aspirating:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<15}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
# 判断枪头是否存在
self.hardware_interface._update_tip_status()
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("无枪头,无法吸液")
return
# 判断吸液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
return
# 移动到吸液位置
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
if blow_out_air_volume >0:
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
# print("Dispensing:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
# 判断枪头是否存在
self.hardware_interface._update_tip_status()
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("无枪头,无法排液")
return
# 判断排液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
return
# 移动到排液位置
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
if blow_out_air_volume > 0:
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
print(f"Picking up tips from {pickup.resource.name}.")
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
print(f"Dropping tips to {drop.resource.name}.")
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
if isinstance(aspiration, MultiHeadAspirationPlate):
resource = aspiration.wells[0].parent
else:
resource = aspiration.container
print(f"Aspirating {aspiration.volume} from {resource}.")
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
print(f"Dispensing {dispense.volume} to {resource}.")
async def pick_up_resource(self, pickup: ResourcePickup):
print(f"Picking up resource: {pickup}")
async def move_picked_up_resource(self, move: ResourceMove):
print(f"Moving picked up resource: {move}")
async def drop_resource(self, drop: ResourceDrop):
print(f"Dropping resource: {drop}")
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
"""
LaiYu_Liquid 控制器模块
该模块包含了LaiYu_Liquid液体处理工作站的高级控制器
- 移液器控制器:提供液体处理的高级接口
- XYZ运动控制器提供三轴运动的高级接口
"""
# 移液器控制器导入
from .pipette_controller import PipetteController
# XYZ运动控制器导入
from .xyz_controller import XYZController
__all__ = [
# 移液器控制器
"PipetteController",
# XYZ运动控制器
"XYZController",
]
__version__ = "1.0.0"
__author__ = "LaiYu_Liquid Controller Team"
__description__ = "LaiYu_Liquid 高级控制器集合"

View File

@@ -0,0 +1,14 @@
{
"machine_origin_steps": {
"x": -198.43,
"y": -94.25,
"z": -0.73
},
"work_origin_steps": {
"x": 59.39,
"y": 216.99,
"z": 2.0
},
"is_homed": true,
"timestamp": "2025-10-29T20:34:11.749055"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,881 @@
"""
LaiYu_Liquid 液体处理工作站主要集成文件
该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。
主要包含:
- LaiYuLiquidBackend: 硬件通信后端
- LaiYuLiquid: 主要接口类
- 相关的异常类和容器类
"""
import asyncio
import logging
import time
from typing import List, Optional, Dict, Any, Union, Tuple
from dataclasses import dataclass
from abc import ABC, abstractmethod
# 基础导入
try:
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
PYLABROBOT_AVAILABLE = True
except ImportError:
# 如果 pylabrobot 不可用,创建基础的模拟类
PYLABROBOT_AVAILABLE = False
class Resource:
def __init__(self, name: str):
self.name = name
class Deck(Resource):
pass
class Plate(Resource):
pass
class TipRack(Resource):
pass
class Tip(Resource):
pass
class Well(Resource):
pass
# LaiYu_Liquid 控制器导入
try:
from .controllers.pipette_controller import (
PipetteController, TipStatus, LiquidClass, LiquidParameters
)
from .controllers.xyz_controller import (
XYZController, MachineConfig, CoordinateOrigin, MotorAxis
)
CONTROLLERS_AVAILABLE = True
except ImportError:
CONTROLLERS_AVAILABLE = False
# 创建模拟的控制器类
class PipetteController:
def __init__(self, *args, **kwargs):
pass
def connect(self):
return True
def initialize(self):
return True
class XYZController:
def __init__(self, *args, **kwargs):
pass
def connect_device(self):
return True
logger = logging.getLogger(__name__)
class LaiYuLiquidError(RuntimeError):
"""LaiYu_Liquid 设备异常"""
pass
@dataclass
class LaiYuLiquidConfig:
"""LaiYu_Liquid 设备配置"""
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
address: int = 1 # 设备地址
baudrate: int = 9600 # 波特率
timeout: float = 5.0 # 通信超时时间
# 工作台尺寸
deck_width: float = 340.0 # 工作台宽度 (mm)
deck_height: float = 250.0 # 工作台高度 (mm)
deck_depth: float = 160.0 # 工作台深度 (mm)
# 移液参数
max_volume: float = 1000.0 # 最大体积 (μL)
min_volume: float = 0.1 # 最小体积 (μL)
# 运动参数
max_speed: float = 100.0 # 最大速度 (mm/s)
acceleration: float = 50.0 # 加速度 (mm/s²)
# 安全参数
safe_height: float = 50.0 # 安全高度 (mm)
tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm)
liquid_detection: bool = True # 液面检测
# 取枪头相关参数
tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm)
tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s)
tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm)
tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm)
tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm)
# 丢弃枪头相关参数
tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm)
tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm)
trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm)
# 安全范围配置
deck_width: float = 300.0 # 工作台宽度 (mm)
deck_height: float = 200.0 # 工作台高度 (mm)
deck_depth: float = 100.0 # 工作台深度 (mm)
safe_height: float = 50.0 # 安全高度 (mm)
position_validation: bool = True # 启用位置验证
emergency_stop_enabled: bool = True # 启用紧急停止
class LaiYuLiquidDeck:
"""LaiYu_Liquid 工作台管理"""
def __init__(self, config: LaiYuLiquidConfig):
self.config = config
self.resources: Dict[str, Resource] = {}
self.positions: Dict[str, Tuple[float, float, float]] = {}
def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]):
"""添加资源到工作台"""
self.resources[name] = resource
self.positions[name] = position
def get_resource(self, name: str) -> Optional[Resource]:
"""获取资源"""
return self.resources.get(name)
def get_position(self, name: str) -> Optional[Tuple[float, float, float]]:
"""获取资源位置"""
return self.positions.get(name)
def list_resources(self) -> List[str]:
"""列出所有资源"""
return list(self.resources.keys())
class LaiYuLiquidContainer:
"""LaiYu_Liquid 容器类"""
def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, container_type: str = "", volume: float = 0.0, max_volume: float = 1000.0, lid_height: float = 0.0):
self.name = name
self.size_x = size_x
self.size_y = size_y
self.size_z = size_z
self.lid_height = lid_height
self.container_type = container_type
self.volume = volume
self.max_volume = max_volume
self.last_updated = time.time()
self.child_resources = {} # 存储子资源
@property
def is_empty(self) -> bool:
return self.volume <= 0.0
@property
def is_full(self) -> bool:
return self.volume >= self.max_volume
@property
def available_volume(self) -> float:
return max(0.0, self.max_volume - self.volume)
def add_volume(self, volume: float) -> bool:
"""添加体积"""
if self.volume + volume <= self.max_volume:
self.volume += volume
self.last_updated = time.time()
return True
return False
def remove_volume(self, volume: float) -> bool:
"""移除体积"""
if self.volume >= volume:
self.volume -= volume
self.last_updated = time.time()
return True
return False
def assign_child_resource(self, resource, location=None):
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
if hasattr(resource, 'name'):
self.child_resources[resource.name] = {
'resource': resource,
'location': location
}
class LaiYuLiquidTipRack:
"""LaiYu_Liquid 吸头架类"""
def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, tip_count: int = 96, tip_volume: float = 1000.0):
self.name = name
self.size_x = size_x
self.size_y = size_y
self.size_z = size_z
self.tip_count = tip_count
self.tip_volume = tip_volume
self.tips_available = [True] * tip_count
self.child_resources = {} # 存储子资源
@property
def available_tips(self) -> int:
return sum(self.tips_available)
@property
def is_empty(self) -> bool:
return self.available_tips == 0
def pick_tip(self, position: int) -> bool:
"""拾取吸头"""
if 0 <= position < self.tip_count and self.tips_available[position]:
self.tips_available[position] = False
return True
return False
def has_tip(self, position: int) -> bool:
"""检查位置是否有吸头"""
if 0 <= position < self.tip_count:
return self.tips_available[position]
return False
def assign_child_resource(self, resource, location=None):
"""分配子资源到指定位置"""
self.child_resources[resource.name] = {
'resource': resource,
'location': location
}
def get_module_info():
"""获取模块信息"""
return {
"name": "LaiYu_Liquid",
"version": "1.0.0",
"description": "LaiYu液体处理工作站模块提供移液器控制、XYZ轴控制和资源管理功能",
"author": "UniLabOS Team",
"capabilities": [
"移液器控制",
"XYZ轴运动控制",
"吸头架管理",
"板和容器管理",
"资源位置管理"
],
"dependencies": {
"required": ["serial"],
"optional": ["pylabrobot"]
}
}
class LaiYuLiquidBackend:
"""LaiYu_Liquid 硬件通信后端"""
def __init__(self, config: LaiYuLiquidConfig, deck: Optional['LaiYuLiquidDeck'] = None):
self.config = config
self.deck = deck # 工作台引用,用于获取资源位置信息
self.pipette_controller = None
self.xyz_controller = None
self.is_connected = False
self.is_initialized = False
# 状态跟踪
self.current_position = (0.0, 0.0, 0.0)
self.tip_attached = False
self.current_volume = 0.0
def _validate_position(self, x: float, y: float, z: float) -> bool:
"""验证位置是否在安全范围内"""
try:
# 检查X轴范围
if not (0 <= x <= self.config.deck_width):
logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]")
return False
# 检查Y轴范围
if not (0 <= y <= self.config.deck_height):
logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]")
return False
# 检查Z轴范围负值表示向下0为工作台表面
if not (-self.config.deck_depth <= z <= self.config.safe_height):
logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]")
return False
return True
except Exception as e:
logger.error(f"位置验证失败: {e}")
return False
def _check_hardware_ready(self) -> bool:
"""检查硬件是否准备就绪"""
if not self.is_connected:
logger.error("设备未连接")
return False
if CONTROLLERS_AVAILABLE:
if self.xyz_controller is None:
logger.error("XYZ控制器未初始化")
return False
return True
async def emergency_stop(self) -> bool:
"""紧急停止所有运动"""
try:
logger.warning("执行紧急停止")
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 停止XYZ控制器
await self.xyz_controller.stop_all_motion()
logger.info("XYZ控制器已停止")
if self.pipette_controller:
# 停止移液器控制器
await self.pipette_controller.stop()
logger.info("移液器控制器已停止")
return True
except Exception as e:
logger.error(f"紧急停止失败: {e}")
return False
async def move_to_safe_position(self) -> bool:
"""移动到安全位置"""
try:
if not self._check_hardware_ready():
return False
safe_position = (
self.config.deck_width / 2, # 工作台中心X
self.config.deck_height / 2, # 工作台中心Y
self.config.safe_height # 安全高度Z
)
if not self._validate_position(*safe_position):
logger.error("安全位置无效")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
await self.xyz_controller.move_to_work_coord(*safe_position)
self.current_position = safe_position
logger.info(f"已移动到安全位置: {safe_position}")
return True
else:
# 模拟模式
self.current_position = safe_position
logger.info("模拟移动到安全位置")
return True
except Exception as e:
logger.error(f"移动到安全位置失败: {e}")
return False
async def setup(self) -> bool:
"""设置硬件连接"""
try:
if CONTROLLERS_AVAILABLE:
# 初始化移液器控制器
self.pipette_controller = PipetteController(
port=self.config.port,
address=self.config.address
)
# 初始化XYZ控制器
machine_config = MachineConfig()
self.xyz_controller = XYZController(
port=self.config.port,
baudrate=self.config.baudrate,
machine_config=machine_config
)
# 连接设备
pipette_connected = await asyncio.to_thread(self.pipette_controller.connect)
xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device)
if pipette_connected and xyz_connected:
self.is_connected = True
logger.info("LaiYu_Liquid 硬件连接成功")
return True
else:
logger.error("LaiYu_Liquid 硬件连接失败")
return False
else:
# 模拟模式
logger.info("LaiYu_Liquid 运行在模拟模式")
self.is_connected = True
return True
except Exception as e:
logger.error(f"LaiYu_Liquid 设置失败: {e}")
return False
async def stop(self):
"""停止设备"""
try:
if self.pipette_controller and hasattr(self.pipette_controller, 'disconnect'):
await asyncio.to_thread(self.pipette_controller.disconnect)
if self.xyz_controller and hasattr(self.xyz_controller, 'disconnect'):
await asyncio.to_thread(self.xyz_controller.disconnect)
self.is_connected = False
self.is_initialized = False
logger.info("LaiYu_Liquid 已停止")
except Exception as e:
logger.error(f"LaiYu_Liquid 停止失败: {e}")
async def move_to(self, x: float, y: float, z: float) -> bool:
"""移动到指定位置"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
# 模拟移动
await asyncio.sleep(0.1) # 模拟移动时间
self.current_position = (x, y, z)
logger.debug(f"移动到位置: ({x}, {y}, {z})")
return True
except Exception as e:
logger.error(f"移动失败: {e}")
return False
async def pick_up_tip(self, tip_rack: str, position: int) -> bool:
"""拾取吸头 - 包含真正的Z轴下降控制"""
try:
# 硬件准备检查
if not self._check_hardware_ready():
return False
if self.tip_attached:
logger.warning("已有吸头附着,无法拾取新吸头")
return False
logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头")
# 获取枪头架位置信息
if self.deck is None:
logger.error("工作台未初始化")
return False
tip_position = self.deck.get_position(tip_rack)
if tip_position is None:
logger.error(f"未找到枪头架 {tip_rack} 的位置信息")
return False
# 计算具体枪头位置这里简化处理实际应根据position计算偏移
tip_x, tip_y, tip_z = tip_position
# 验证所有关键位置的安全性
safe_z = tip_z + self.config.tip_approach_height
pickup_z = tip_z - self.config.tip_pickup_force_depth
retract_z = tip_z + self.config.tip_pickup_retract_height
if not (self._validate_position(tip_x, tip_y, safe_z) and
self._validate_position(tip_x, tip_y, pickup_z) and
self._validate_position(tip_x, tip_y, retract_z)):
logger.error("枪头拾取位置超出安全范围")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 真实硬件控制流程
logger.info("使用真实XYZ控制器进行枪头拾取")
try:
# 1. 移动到枪头上方的安全位置
safe_z = tip_z + self.config.tip_approach_height
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
move_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
tip_x, tip_y, safe_z
)
if not move_success:
logger.error("移动到枪头上方失败")
return False
# 2. Z轴下降到枪头位置
pickup_z = tip_z - self.config.tip_pickup_force_depth
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
z_down_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
tip_x, tip_y, pickup_z
)
if not z_down_success:
logger.error("Z轴下降到枪头位置失败")
return False
# 3. 等待一小段时间确保枪头牢固附着
await asyncio.sleep(0.2)
# 4. Z轴上升到回退高度
retract_z = tip_z + self.config.tip_pickup_retract_height
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
z_up_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
tip_x, tip_y, retract_z
)
if not z_up_success:
logger.error("Z轴上升失败")
return False
# 5. 更新当前位置
self.current_position = (tip_x, tip_y, retract_z)
except Exception as move_error:
logger.error(f"枪头拾取过程中发生错误: {move_error}")
# 尝试移动到安全位置
if self.config.emergency_stop_enabled:
await self.emergency_stop()
await self.move_to_safe_position()
return False
else:
# 模拟模式
logger.info("模拟模式:执行枪头拾取动作")
await asyncio.sleep(1.0) # 模拟整个拾取过程的时间
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
# 6. 标记枪头已附着
self.tip_attached = True
logger.info("吸头拾取成功")
return True
except Exception as e:
logger.error(f"拾取吸头失败: {e}")
return False
async def drop_tip(self, location: str = "trash") -> bool:
"""丢弃吸头 - 包含真正的Z轴控制"""
try:
# 硬件准备检查
if not self._check_hardware_ready():
return False
if not self.tip_attached:
logger.warning("没有吸头附着,无需丢弃")
return True
logger.info(f"开始丢弃吸头到 {location}")
# 确定丢弃位置
if location == "trash":
# 使用配置中的垃圾桶位置
drop_x, drop_y, drop_z = self.config.trash_position
else:
# 尝试从deck获取指定位置
if self.deck is None:
logger.error("工作台未初始化")
return False
drop_position = self.deck.get_position(location)
if drop_position is None:
logger.error(f"未找到丢弃位置 {location} 的信息")
return False
drop_x, drop_y, drop_z = drop_position
# 验证丢弃位置的安全性
safe_z = drop_z + self.config.safe_height
drop_height_z = drop_z + self.config.tip_drop_height
if not (self._validate_position(drop_x, drop_y, safe_z) and
self._validate_position(drop_x, drop_y, drop_height_z)):
logger.error("枪头丢弃位置超出安全范围")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 真实硬件控制流程
logger.info("使用真实XYZ控制器进行枪头丢弃")
try:
# 1. 移动到丢弃位置上方的安全高度
safe_z = drop_z + self.config.tip_drop_height
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
move_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
drop_x, drop_y, safe_z
)
if not move_success:
logger.error("移动到丢弃位置上方失败")
return False
# 2. Z轴下降到丢弃高度
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
z_down_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
drop_x, drop_y, drop_z
)
if not z_down_success:
logger.error("Z轴下降到丢弃位置失败")
return False
# 3. 执行枪头弹出动作(如果有移液器控制器)
if self.pipette_controller:
try:
# 发送弹出枪头命令
await asyncio.to_thread(self.pipette_controller.eject_tip)
logger.info("执行枪头弹出命令")
except Exception as e:
logger.warning(f"枪头弹出命令失败: {e}")
# 4. 等待一小段时间确保枪头完全脱离
await asyncio.sleep(0.3)
# 5. Z轴上升到安全高度
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
z_up_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord,
drop_x, drop_y, safe_z
)
if not z_up_success:
logger.error("Z轴上升失败")
return False
# 6. 更新当前位置
self.current_position = (drop_x, drop_y, safe_z)
except Exception as drop_error:
logger.error(f"枪头丢弃过程中发生错误: {drop_error}")
# 尝试移动到安全位置
if self.config.emergency_stop_enabled:
await self.emergency_stop()
await self.move_to_safe_position()
return False
else:
# 模拟模式
logger.info("模拟模式:执行枪头丢弃动作")
await asyncio.sleep(0.8) # 模拟整个丢弃过程的时间
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
# 7. 标记枪头已脱离,清空体积
self.tip_attached = False
self.current_volume = 0.0
logger.info("吸头丢弃成功")
return True
except Exception as e:
logger.error(f"丢弃吸头失败: {e}")
return False
async def aspirate(self, volume: float, location: str) -> bool:
"""吸取液体"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
if not self.tip_attached:
raise LaiYuLiquidError("没有吸头附着")
if volume <= 0 or volume > self.config.max_volume:
raise LaiYuLiquidError(f"体积超出范围: {volume}")
# 模拟吸取
await asyncio.sleep(0.3)
self.current_volume += volume
logger.debug(f"{location} 吸取 {volume} μL")
return True
except Exception as e:
logger.error(f"吸取失败: {e}")
return False
async def dispense(self, volume: float, location: str) -> bool:
"""分配液体"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
if not self.tip_attached:
raise LaiYuLiquidError("没有吸头附着")
if volume <= 0 or volume > self.current_volume:
raise LaiYuLiquidError(f"分配体积无效: {volume}")
# 模拟分配
await asyncio.sleep(0.3)
self.current_volume -= volume
logger.debug(f"{location} 分配 {volume} μL")
return True
except Exception as e:
logger.error(f"分配失败: {e}")
return False
class LaiYuLiquid:
"""LaiYu_Liquid 主要接口类"""
def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs):
# 如果传入了关键字参数,创建配置对象
if kwargs and config is None:
# 从kwargs中提取配置参数
config_params = {}
for key, value in kwargs.items():
if hasattr(LaiYuLiquidConfig, key):
config_params[key] = value
self.config = LaiYuLiquidConfig(**config_params)
else:
self.config = config or LaiYuLiquidConfig()
# 先创建deck然后传递给backend
self.deck = LaiYuLiquidDeck(self.config)
self.backend = LaiYuLiquidBackend(self.config, self.deck)
self.is_setup = False
@property
def current_position(self) -> Tuple[float, float, float]:
"""获取当前位置"""
return self.backend.current_position
@property
def current_volume(self) -> float:
"""获取当前体积"""
return self.backend.current_volume
@property
def is_connected(self) -> bool:
"""获取连接状态"""
return self.backend.is_connected
@property
def is_initialized(self) -> bool:
"""获取初始化状态"""
return self.backend.is_initialized
@property
def tip_attached(self) -> bool:
"""获取吸头附着状态"""
return self.backend.tip_attached
async def setup(self) -> bool:
"""设置液体处理器"""
try:
success = await self.backend.setup()
if success:
self.is_setup = True
logger.info("LaiYu_Liquid 设置完成")
return success
except Exception as e:
logger.error(f"LaiYu_Liquid 设置失败: {e}")
return False
async def stop(self):
"""停止液体处理器"""
await self.backend.stop()
self.is_setup = False
async def transfer(self, source: str, target: str, volume: float,
tip_rack: str = "tip_rack_1", tip_position: int = 0) -> bool:
"""液体转移"""
try:
if not self.is_setup:
raise LaiYuLiquidError("设备未设置")
# 获取源和目标位置
source_pos = self.deck.get_position(source)
target_pos = self.deck.get_position(target)
tip_pos = self.deck.get_position(tip_rack)
if not all([source_pos, target_pos, tip_pos]):
raise LaiYuLiquidError("位置信息不完整")
# 执行转移步骤
steps = [
("移动到吸头架", self.backend.move_to(*tip_pos)),
("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)),
("移动到源位置", self.backend.move_to(*source_pos)),
("吸取液体", self.backend.aspirate(volume, source)),
("移动到目标位置", self.backend.move_to(*target_pos)),
("分配液体", self.backend.dispense(volume, target)),
("丢弃吸头", self.backend.drop_tip())
]
for step_name, step_coro in steps:
logger.debug(f"执行步骤: {step_name}")
success = await step_coro
if not success:
raise LaiYuLiquidError(f"步骤失败: {step_name}")
logger.info(f"液体转移完成: {source} -> {target}, {volume} μL")
return True
except Exception as e:
logger.error(f"液体转移失败: {e}")
return False
def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]):
"""添加资源到工作台"""
if resource_type == "plate":
resource = Plate(name)
elif resource_type == "tip_rack":
resource = TipRack(name)
else:
resource = Resource(name)
self.deck.add_resource(name, resource, position)
def get_status(self) -> Dict[str, Any]:
"""获取设备状态"""
return {
"connected": self.backend.is_connected,
"setup": self.is_setup,
"current_position": self.backend.current_position,
"tip_attached": self.backend.tip_attached,
"current_volume": self.backend.current_volume,
"resources": self.deck.list_resources()
}
def create_quick_setup() -> LaiYuLiquidDeck:
"""
创建快速设置的LaiYu液体处理工作站
Returns:
LaiYuLiquidDeck: 配置好的工作台实例
"""
# 创建默认配置
config = LaiYuLiquidConfig()
# 创建工作台
deck = LaiYuLiquidDeck(config)
# 导入资源创建函数
try:
from .laiyu_liquid_res import (
create_tip_rack_1000ul,
create_tip_rack_200ul,
create_96_well_plate,
create_waste_container
)
# 添加基本资源
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
plate_96 = create_96_well_plate("plate_96")
waste = create_waste_container("waste")
# 添加到工作台
deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0))
deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0))
deck.add_resource("plate_96", plate_96, (250, 50, 0))
deck.add_resource("waste", waste, (50, 150, 0))
except ImportError:
# 如果资源模块不可用,创建空的工作台
logger.warning("资源模块不可用,创建空的工作台")
return deck
__all__ = [
"LaiYuLiquid",
"LaiYuLiquidBackend",
"LaiYuLiquidConfig",
"LaiYuLiquidDeck",
"LaiYuLiquidContainer",
"LaiYuLiquidTipRack",
"LaiYuLiquidError",
"create_quick_setup",
"get_module_info"
]

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LaiYu液体处理设备核心模块
该模块包含LaiYu液体处理设备的核心功能组件
- LaiYu_Liquid.py: 主设备类和配置管理
- abstract_protocol.py: 抽象协议定义
- laiyu_liquid_res.py: 设备资源管理
作者: UniLab团队
版本: 2.0.0
"""
from .LaiYu_Liquid import (
LaiYuLiquid,
LaiYuLiquidConfig,
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack,
create_quick_setup
)
from .laiyu_liquid_res import (
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack
)
__all__ = [
# 主设备类
'LaiYuLiquid',
'LaiYuLiquidConfig',
# 设备资源
'LaiYuLiquidDeck',
'LaiYuLiquidContainer',
'LaiYuLiquidTipRack',
# 工具函数
'create_quick_setup'
]

View File

@@ -0,0 +1,529 @@
"""
LaiYu_Liquid 抽象协议实现
该模块提供了液体资源管理和转移的抽象协议,包括:
- MaterialResource: 液体资源管理类
- transfer_liquid: 液体转移函数
- 相关的辅助类和函数
主要功能:
- 管理多孔位的液体资源
- 计算和跟踪液体体积
- 处理液体转移操作
- 提供资源状态查询
"""
import logging
from typing import Dict, List, Optional, Union, Any, Tuple
from dataclasses import dataclass, field
from enum import Enum
import uuid
import time
# pylabrobot 导入
from pylabrobot.resources import Resource, Well, Plate
logger = logging.getLogger(__name__)
class LiquidType(Enum):
"""液体类型枚举"""
WATER = "water"
ETHANOL = "ethanol"
DMSO = "dmso"
BUFFER = "buffer"
SAMPLE = "sample"
REAGENT = "reagent"
WASTE = "waste"
UNKNOWN = "unknown"
@dataclass
class LiquidInfo:
"""液体信息类"""
liquid_type: LiquidType = LiquidType.UNKNOWN
volume: float = 0.0 # 体积 (μL)
concentration: Optional[float] = None # 浓度 (mg/ml, M等)
ph: Optional[float] = None # pH值
temperature: Optional[float] = None # 温度 (°C)
viscosity: Optional[float] = None # 粘度 (cP)
density: Optional[float] = None # 密度 (g/ml)
description: str = "" # 描述信息
def __str__(self) -> str:
return f"{self.liquid_type.value}({self.description})"
@dataclass
class WellContent:
"""孔位内容类"""
volume: float = 0.0 # 当前体积 (ul)
max_volume: float = 1000.0 # 最大容量 (ul)
liquid_info: LiquidInfo = field(default_factory=LiquidInfo)
last_updated: float = field(default_factory=time.time)
@property
def is_empty(self) -> bool:
"""检查是否为空"""
return self.volume <= 0.0
@property
def is_full(self) -> bool:
"""检查是否已满"""
return self.volume >= self.max_volume
@property
def available_volume(self) -> float:
"""可用体积"""
return max(0.0, self.max_volume - self.volume)
@property
def fill_percentage(self) -> float:
"""填充百分比"""
return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0
def can_add_volume(self, volume: float) -> bool:
"""检查是否可以添加指定体积"""
return (self.volume + volume) <= self.max_volume
def can_remove_volume(self, volume: float) -> bool:
"""检查是否可以移除指定体积"""
return self.volume >= volume
def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool:
"""
添加液体体积
Args:
volume: 要添加的体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功添加
"""
if not self.can_add_volume(volume):
return False
self.volume += volume
if liquid_info:
self.liquid_info = liquid_info
self.last_updated = time.time()
return True
def remove_volume(self, volume: float) -> bool:
"""
移除液体体积
Args:
volume: 要移除的体积 (ul)
Returns:
bool: 是否成功移除
"""
if not self.can_remove_volume(volume):
return False
self.volume -= volume
self.last_updated = time.time()
# 如果完全清空,重置液体信息
if self.volume <= 0.0:
self.volume = 0.0
self.liquid_info = LiquidInfo()
return True
class MaterialResource:
"""
液体资源管理类
该类用于管理液体处理过程中的资源状态,包括:
- 跟踪多个孔位的液体体积和类型
- 计算总体积和可用体积
- 处理液体的添加和移除
- 提供资源状态查询
"""
def __init__(
self,
resource: Resource,
wells: Optional[List[Well]] = None,
default_max_volume: float = 1000.0
):
"""
初始化材料资源
Args:
resource: pylabrobot 资源对象
wells: 孔位列表如果为None则自动获取
default_max_volume: 默认最大体积 (ul)
"""
self.resource = resource
self.resource_id = str(uuid.uuid4())
self.default_max_volume = default_max_volume
# 获取孔位列表
if wells is None:
if hasattr(resource, 'get_wells'):
self.wells = resource.get_wells()
elif hasattr(resource, 'wells'):
self.wells = resource.wells
else:
# 如果没有孔位,创建一个虚拟孔位
self.wells = [resource]
else:
self.wells = wells
# 初始化孔位内容
self.well_contents: Dict[str, WellContent] = {}
for well in self.wells:
well_id = self._get_well_id(well)
self.well_contents[well_id] = WellContent(
max_volume=default_max_volume
)
logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}")
def _get_well_id(self, well: Union[Well, Resource]) -> str:
"""获取孔位ID"""
if hasattr(well, 'name'):
return well.name
else:
return str(id(well))
@property
def name(self) -> str:
"""资源名称"""
return self.resource.name
@property
def total_volume(self) -> float:
"""总液体体积"""
return sum(content.volume for content in self.well_contents.values())
@property
def total_max_volume(self) -> float:
"""总最大容量"""
return sum(content.max_volume for content in self.well_contents.values())
@property
def available_volume(self) -> float:
"""总可用体积"""
return sum(content.available_volume for content in self.well_contents.values())
@property
def well_count(self) -> int:
"""孔位数量"""
return len(self.wells)
@property
def empty_wells(self) -> List[str]:
"""空孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if content.is_empty]
@property
def full_wells(self) -> List[str]:
"""满孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if content.is_full]
@property
def occupied_wells(self) -> List[str]:
"""有液体的孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if not content.is_empty]
def get_well_content(self, well_id: str) -> Optional[WellContent]:
"""获取指定孔位的内容"""
return self.well_contents.get(well_id)
def get_well_volume(self, well_id: str) -> float:
"""获取指定孔位的体积"""
content = self.get_well_content(well_id)
return content.volume if content else 0.0
def set_well_volume(
self,
well_id: str,
volume: float,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
设置指定孔位的体积
Args:
well_id: 孔位ID
volume: 体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功设置
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
if volume > content.max_volume:
logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}")
return False
content.volume = max(0.0, volume)
if liquid_info:
content.liquid_info = liquid_info
content.last_updated = time.time()
logger.info(f"设置孔位 {well_id} 体积: {volume}ul")
return True
def add_liquid(
self,
well_id: str,
volume: float,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
向指定孔位添加液体
Args:
well_id: 孔位ID
volume: 添加的体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功添加
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
success = content.add_volume(volume, liquid_info)
if success:
logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体")
else:
logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体")
return success
def remove_liquid(self, well_id: str, volume: float) -> bool:
"""
从指定孔位移除液体
Args:
well_id: 孔位ID
volume: 移除的体积 (ul)
Returns:
bool: 是否成功移除
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
success = content.remove_volume(volume)
if success:
logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体")
else:
logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体")
return success
def find_wells_with_volume(self, min_volume: float) -> List[str]:
"""
查找具有指定最小体积的孔位
Args:
min_volume: 最小体积 (ul)
Returns:
List[str]: 符合条件的孔位ID列表
"""
return [well_id for well_id, content in self.well_contents.items()
if content.volume >= min_volume]
def find_wells_with_space(self, min_space: float) -> List[str]:
"""
查找具有指定最小空间的孔位
Args:
min_space: 最小空间 (ul)
Returns:
List[str]: 符合条件的孔位ID列表
"""
return [well_id for well_id, content in self.well_contents.items()
if content.available_volume >= min_space]
def get_status_summary(self) -> Dict[str, Any]:
"""获取资源状态摘要"""
return {
"resource_name": self.name,
"resource_id": self.resource_id,
"well_count": self.well_count,
"total_volume": self.total_volume,
"total_max_volume": self.total_max_volume,
"available_volume": self.available_volume,
"fill_percentage": (self.total_volume / self.total_max_volume) * 100.0,
"empty_wells": len(self.empty_wells),
"full_wells": len(self.full_wells),
"occupied_wells": len(self.occupied_wells)
}
def get_detailed_status(self) -> Dict[str, Any]:
"""获取详细状态信息"""
well_details = {}
for well_id, content in self.well_contents.items():
well_details[well_id] = {
"volume": content.volume,
"max_volume": content.max_volume,
"available_volume": content.available_volume,
"fill_percentage": content.fill_percentage,
"liquid_type": content.liquid_info.liquid_type.value,
"description": content.liquid_info.description,
"last_updated": content.last_updated
}
return {
"summary": self.get_status_summary(),
"wells": well_details
}
def transfer_liquid(
source: MaterialResource,
target: MaterialResource,
volume: float,
source_well_id: Optional[str] = None,
target_well_id: Optional[str] = None,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
在两个材料资源之间转移液体
Args:
source: 源资源
target: 目标资源
volume: 转移体积 (ul)
source_well_id: 源孔位ID如果为None则自动选择
target_well_id: 目标孔位ID如果为None则自动选择
liquid_info: 液体信息
Returns:
bool: 转移是否成功
"""
try:
# 自动选择源孔位
if source_well_id is None:
available_wells = source.find_wells_with_volume(volume)
if not available_wells:
logger.error(f"源资源 {source.name} 没有足够体积的孔位")
return False
source_well_id = available_wells[0]
# 自动选择目标孔位
if target_well_id is None:
available_wells = target.find_wells_with_space(volume)
if not available_wells:
logger.error(f"目标资源 {target.name} 没有足够空间的孔位")
return False
target_well_id = available_wells[0]
# 检查源孔位是否有足够液体
if not source.get_well_content(source_well_id).can_remove_volume(volume):
logger.error(f"源孔位 {source_well_id} 液体不足")
return False
# 检查目标孔位是否有足够空间
if not target.get_well_content(target_well_id).can_add_volume(volume):
logger.error(f"目标孔位 {target_well_id} 空间不足")
return False
# 获取源液体信息
source_content = source.get_well_content(source_well_id)
transfer_liquid_info = liquid_info or source_content.liquid_info
# 执行转移
if source.remove_liquid(source_well_id, volume):
if target.add_liquid(target_well_id, volume, transfer_liquid_info):
logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]")
return True
else:
# 如果目标添加失败,回滚源操作
source.add_liquid(source_well_id, volume, source_content.liquid_info)
logger.error("目标添加失败,已回滚源操作")
return False
else:
logger.error("源移除失败")
return False
except Exception as e:
logger.error(f"液体转移失败: {e}")
return False
def create_material_resource(
name: str,
resource: Resource,
initial_volumes: Optional[Dict[str, float]] = None,
liquid_info: Optional[LiquidInfo] = None,
max_volume: float = 1000.0
) -> MaterialResource:
"""
创建材料资源的便捷函数
Args:
name: 资源名称
resource: pylabrobot 资源对象
initial_volumes: 初始体积字典 {well_id: volume}
liquid_info: 液体信息
max_volume: 最大体积
Returns:
MaterialResource: 创建的材料资源
"""
material_resource = MaterialResource(
resource=resource,
default_max_volume=max_volume
)
# 设置初始体积
if initial_volumes:
for well_id, volume in initial_volumes.items():
material_resource.set_well_volume(well_id, volume, liquid_info)
return material_resource
def batch_transfer_liquid(
transfers: List[Tuple[MaterialResource, MaterialResource, float]],
liquid_info: Optional[LiquidInfo] = None
) -> List[bool]:
"""
批量液体转移
Args:
transfers: 转移列表 [(source, target, volume), ...]
liquid_info: 液体信息
Returns:
List[bool]: 每个转移操作的结果
"""
results = []
for source, target, volume in transfers:
result = transfer_liquid(source, target, volume, liquid_info=liquid_info)
results.append(result)
if not result:
logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}")
success_count = sum(results)
logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功")
return results

View File

@@ -0,0 +1,954 @@
"""
LaiYu_Liquid 资源定义模块
该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括:
- 各种规格的枪头架
- 不同类型的板和容器
- 特殊功能位置
- 资源创建的便捷函数
所有资源都基于 deck.json 中的配置参数创建。
"""
import json
import os
from typing import Dict, List, Optional, Tuple, Any
from pathlib import Path
# PyLabRobot 资源导入
try:
from pylabrobot.resources import (
Resource, Deck, Plate, TipRack, Container, Tip,
Coordinate
)
from pylabrobot.resources.tip_rack import TipSpot
from pylabrobot.resources.well import Well as PlateWell
PYLABROBOT_AVAILABLE = True
except ImportError:
# 如果 PyLabRobot 不可用,创建模拟类
PYLABROBOT_AVAILABLE = False
class Resource:
def __init__(self, name: str):
self.name = name
class Deck(Resource):
pass
class Plate(Resource):
pass
class TipRack(Resource):
pass
class Container(Resource):
pass
class Tip(Resource):
pass
class TipSpot(Resource):
def __init__(self, name: str, **kwargs):
super().__init__(name)
# 忽略其他参数
class PlateWell(Resource):
pass
class Coordinate:
def __init__(self, x: float, y: float, z: float):
self.x = x
self.y = y
self.z = z
# 本地导入
from .LaiYu_Liquid import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack
def load_deck_config() -> Dict[str, Any]:
"""
加载工作台配置文件
Returns:
Dict[str, Any]: 配置字典
"""
# 优先使用最新的deckconfig.json文件
config_path = Path(__file__).parent / "controllers" / "deckconfig.json"
# 如果最新配置文件不存在,回退到旧配置文件
if not config_path.exists():
config_path = Path(__file__).parent / "config" / "deck.json"
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
# 如果找不到配置文件,返回默认配置
return {
"name": "LaiYu_Liquid_Deck",
"size_x": 340.0,
"size_y": 250.0,
"size_z": 160.0
}
# 加载配置
DECK_CONFIG = load_deck_config()
class LaiYuTipRack1000(LaiYuLiquidTipRack):
"""1000μL 枪头架"""
def __init__(self, name: str):
"""
初始化1000μL枪头架
Args:
name: 枪头架名称
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
tip_count=96,
tip_volume=1000.0
)
# 创建枪头位置
self._create_tip_spots(
tip_count=96,
tip_spacing=9.0,
tip_type="1000ul"
)
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
"""
创建枪头位置 - 从配置文件中读取绝对坐标
Args:
tip_count: 枪头数量
tip_spacing: 枪头间距
tip_type: 枪头类型
"""
# 从配置文件中获取枪头架的孔位信息
config = DECK_CONFIG
tip_module = None
# 查找枪头架模块
for module in config.get("children", []):
if module.get("type") == "tip_rack":
tip_module = module
break
if not tip_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
spot_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * tip_spacing + tip_spacing / 2
y = row * tip_spacing + tip_spacing / 2
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=95.0, # 1000ul枪头长度
maximal_volume=1000.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=9.0, # 枪头位置宽度
size_y=9.0, # 枪头位置深度
size_z=95.0, # 枪头位置高度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in tip_module.get("wells", []):
spot_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=95.0, # 1000ul枪头长度
maximal_volume=1000.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=well_config.get("diameter", 9.0), # 使用配置中的直径
size_y=well_config.get("diameter", 9.0),
size_z=well_config.get("depth", 95.0), # 使用配置中的深度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(relative_x, relative_y, relative_z)
)
# 注意在PyLabRobot中Tip不是Resource不需要分配给TipSpot
# TipSpot的make_tip函数会在需要时创建Tip
class LaiYuTipRack200(LaiYuLiquidTipRack):
"""200μL 枪头架"""
def __init__(self, name: str):
"""
初始化200μL枪头架
Args:
name: 枪头架名称
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
tip_count=96,
tip_volume=200.0
)
# 创建枪头位置
self._create_tip_spots(
tip_count=96,
tip_spacing=9.0,
tip_type="200ul"
)
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
"""
创建枪头位置
Args:
tip_count: 枪头数量
tip_spacing: 枪头间距
tip_type: 枪头类型
"""
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
spot_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * tip_spacing + tip_spacing / 2
y = row * tip_spacing + tip_spacing / 2
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=72.0, # 200ul枪头长度
maximal_volume=200.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=9.0, # 枪头位置宽度
size_y=9.0, # 枪头位置深度
size_z=72.0, # 枪头位置高度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(x, y, 0)
)
# 注意在PyLabRobot中Tip不是Resource不需要分配给TipSpot
# TipSpot的make_tip函数会在需要时创建Tip
class LaiYu96WellPlate(LaiYuLiquidContainer):
"""96孔板"""
def __init__(self, name: str, lid_height: float = 0.0):
"""
初始化96孔板
Args:
name: 板名称
lid_height: 盖子高度
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=14.22,
container_type="96_well_plate",
volume=0.0,
max_volume=200.0,
lid_height=lid_height
)
# 创建孔位
self._create_wells(
well_count=96,
well_volume=200.0,
well_spacing=9.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 10.0 # 96孔板孔位深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取96孔板的孔位信息
config = DECK_CONFIG
plate_module = None
# 查找96孔板模块
for module in config.get("children", []):
if module.get("type") == "96_well_plate":
plate_module = module
break
if not plate_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_spacing * 0.8,
size_y=well_spacing * 0.8,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in plate_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
size_y=well_config.get("diameter", 8.2) * 0.8,
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuDeepWellPlate(LaiYuLiquidContainer):
"""深孔板"""
def __init__(self, name: str, lid_height: float = 0.0):
"""
初始化深孔板
Args:
name: 板名称
lid_height: 盖子高度
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=41.3,
container_type="deep_well_plate",
volume=0.0,
max_volume=2000.0,
lid_height=lid_height
)
# 创建孔位
self._create_wells(
well_count=96,
well_volume=2000.0,
well_spacing=9.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 35.0 # 深孔板孔位深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取深孔板的孔位信息
config = DECK_CONFIG
plate_module = None
# 查找深孔板模块通常是第二个96孔板模块
plate_modules = []
for module in config.get("children", []):
if module.get("type") == "96_well_plate":
plate_modules.append(module)
# 如果有多个96孔板模块选择第二个作为深孔板
if len(plate_modules) > 1:
plate_module = plate_modules[1]
elif len(plate_modules) == 1:
plate_module = plate_modules[0]
if not plate_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_spacing * 0.8,
size_y=well_spacing * 0.8,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in plate_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
size_y=well_config.get("diameter", 8.2) * 0.8,
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuWasteContainer(Container):
"""废液容器"""
def __init__(self, name: str):
"""
初始化废液容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0,
max_volume=5000.0
)
class LaiYuWashContainer(Container):
"""清洗容器"""
def __init__(self, name: str):
"""
初始化清洗容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0,
max_volume=5000.0
)
class LaiYuReagentContainer(Container):
"""试剂容器"""
def __init__(self, name: str):
"""
初始化试剂容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=50.0,
size_y=50.0,
size_z=100.0,
max_volume=2000.0
)
class LaiYu8TubeRack(LaiYuLiquidContainer):
"""8管试管架"""
def __init__(self, name: str):
"""
初始化8管试管架
Args:
name: 试管架名称
"""
super().__init__(
name=name,
size_x=151.0,
size_y=75.0,
size_z=75.0,
container_type="tube_rack",
volume=0.0,
max_volume=77000.0
)
# 创建孔位
self._create_wells(
well_count=8,
well_volume=77000.0,
well_spacing=35.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 117.0 # 试管深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取8管试管架的孔位信息
config = DECK_CONFIG
tube_module = None
# 查找8管试管架模块
for module in config.get("children", []):
if module.get("type") == "tube_rack":
tube_module = module
break
if not tube_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 2
cols = 4
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=29.0,
size_y=29.0,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到试管架
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in tube_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 29.0),
size_y=well_config.get("diameter", 29.0),
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到试管架
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuTipDisposal(Resource):
"""枪头废料位置"""
def __init__(self, name: str):
"""
初始化枪头废料位置
Args:
name: 位置名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0
)
class LaiYuMaintenancePosition(Resource):
"""维护位置"""
def __init__(self, name: str):
"""
初始化维护位置
Args:
name: 位置名称
"""
super().__init__(
name=name,
size_x=50.0,
size_y=50.0,
size_z=100.0
)
# 资源创建函数
def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000:
"""
创建1000μL枪头架
Args:
name: 枪头架名称
Returns:
LaiYuTipRack1000: 1000μL枪头架实例
"""
return LaiYuTipRack1000(name)
def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200:
"""
创建200μL枪头架
Args:
name: 枪头架名称
Returns:
LaiYuTipRack200: 200μL枪头架实例
"""
return LaiYuTipRack200(name)
def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate:
"""
创建96孔板
Args:
name: 板名称
lid_height: 盖子高度
Returns:
LaiYu96WellPlate: 96孔板实例
"""
return LaiYu96WellPlate(name, lid_height)
def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate:
"""
创建深孔板
Args:
name: 板名称
lid_height: 盖子高度
Returns:
LaiYuDeepWellPlate: 深孔板实例
"""
return LaiYuDeepWellPlate(name, lid_height)
def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack:
"""
创建8管试管架
Args:
name: 试管架名称
Returns:
LaiYu8TubeRack: 8管试管架实例
"""
return LaiYu8TubeRack(name)
def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer:
"""
创建废液容器
Args:
name: 容器名称
Returns:
LaiYuWasteContainer: 废液容器实例
"""
return LaiYuWasteContainer(name)
def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer:
"""
创建清洗容器
Args:
name: 容器名称
Returns:
LaiYuWashContainer: 清洗容器实例
"""
return LaiYuWashContainer(name)
def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer:
"""
创建试剂容器
Args:
name: 容器名称
Returns:
LaiYuReagentContainer: 试剂容器实例
"""
return LaiYuReagentContainer(name)
def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal:
"""
创建枪头废料位置
Args:
name: 位置名称
Returns:
LaiYuTipDisposal: 枪头废料位置实例
"""
return LaiYuTipDisposal(name)
def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition:
"""
创建维护位置
Args:
name: 位置名称
Returns:
LaiYuMaintenancePosition: 维护位置实例
"""
return LaiYuMaintenancePosition(name)
def create_standard_deck() -> LaiYuLiquidDeck:
"""
创建标准工作台配置
Returns:
LaiYuLiquidDeck: 配置好的工作台实例
"""
# 从配置文件创建工作台
deck = LaiYuLiquidDeck(config=DECK_CONFIG)
return deck
def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]:
"""
根据名称获取资源
Args:
deck: 工作台实例
name: 资源名称
Returns:
Optional[Resource]: 找到的资源如果不存在则返回None
"""
for child in deck.children:
if child.name == name:
return child
return None
def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]:
"""
根据类型获取资源列表
Args:
deck: 工作台实例
resource_type: 资源类型
Returns:
List[Resource]: 匹配类型的资源列表
"""
return [child for child in deck.children if isinstance(child, resource_type)]
def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]:
"""
列出所有资源
Args:
deck: 工作台实例
Returns:
Dict[str, List[str]]: 按类型分组的资源名称字典
"""
resources = {
"tip_racks": [],
"plates": [],
"containers": [],
"positions": []
}
for child in deck.children:
if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)):
resources["tip_racks"].append(child.name)
elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)):
resources["plates"].append(child.name)
elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)):
resources["containers"].append(child.name)
elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)):
resources["positions"].append(child.name)
return resources
# 导出的类别名(向后兼容)
TipRack1000ul = LaiYuTipRack1000
TipRack200ul = LaiYuTipRack200
Plate96Well = LaiYu96WellPlate
Plate96DeepWell = LaiYuDeepWellPlate
TubeRack8 = LaiYu8TubeRack
WasteContainer = LaiYuWasteContainer
WashContainer = LaiYuWashContainer
ReagentContainer = LaiYuReagentContainer
TipDisposal = LaiYuTipDisposal
MaintenancePosition = LaiYuMaintenancePosition

View File

@@ -0,0 +1,69 @@
# 更新日志
本文档记录了 LaiYu_Liquid 模块的所有重要变更。
## [1.0.0] - 2024-01-XX
### 新增功能
- ✅ 完整的液体处理工作站集成
- ✅ RS485 通信协议支持
- ✅ SOPA 气动式移液器驱动
- ✅ XYZ 三轴步进电机控制
- ✅ PyLabRobot 兼容后端
- ✅ 标准化资源管理系统
- ✅ 96孔板、离心管架、枪头架支持
- ✅ RViz 可视化后端
- ✅ 完整的配置管理系统
- ✅ 抽象协议实现
- ✅ 生产级错误处理和日志记录
### 技术特性
- **硬件支持**: SOPA移液器 + XYZ三轴运动平台
- **通信协议**: RS485总线波特率115200
- **坐标系统**: 机械坐标与工作坐标自动转换
- **安全机制**: 限位保护、紧急停止、错误恢复
- **兼容性**: 完全兼容 PyLabRobot 框架
### 文件结构
```
LaiYu_Liquid/
├── core/
│ └── LaiYu_Liquid.py # 主模块文件
├── __init__.py # 模块初始化
├── abstract_protocol.py # 抽象协议
├── laiyu_liquid_res.py # 资源管理
├── rviz_backend.py # RViz后端
├── backend/ # 后端驱动
├── config/ # 配置文件
├── controllers/ # 控制器
├── docs/ # 技术文档
└── drivers/ # 底层驱动
```
### 已知问题
-
### 依赖要求
- Python 3.8+
- PyLabRobot
- pyserial
- asyncio
---
## 版本说明
### 版本号格式
采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH`
- **MAJOR**: 不兼容的API变更
- **MINOR**: 向后兼容的功能新增
- **PATCH**: 向后兼容的问题修复
### 变更类型
- **新增功能**: 新的功能特性
- **变更**: 现有功能的变更
- **弃用**: 即将移除的功能
- **移除**: 已移除的功能
- **修复**: 问题修复
- **安全**: 安全相关的修复

View File

@@ -0,0 +1,267 @@
# SOPA气动式移液器RS485控制指令合集
## 1. RS485通信基本配置
### 1.1 支持的设备型号
- **仅SC-STxxx-00-13支持RS485通信**
- 其他型号主要使用CAN通信
### 1.2 通信参数
- **波特率**: 9600, 115200默认值
- **地址范围**: 1~254个设备255为广播地址
- **通信接口**: RS485差分信号
### 1.3 引脚分配10位LIF连接器
- **引脚7**: RS485+ (RS485通信正极)
- **引脚8**: RS485- (RS485通信负极)
## 2. RS485通信协议格式
### 2.1 发送数据格式
```
头码 | 地址 | 命令/数据 | 尾码 | 校验和
```
### 2.2 从机回应格式
```
头码 | 地址 | 数据固定9字节 | 尾码 | 校验和
```
### 2.3 格式详细说明
- **头码**:
- 终端调试: '/' (0x2F)
- OEM通信: '[' (0x5B)
- **地址**: 设备节点地址1~254多字节ASCII注意地址不可为476991
- **命令/数据**: ASCII格式的命令字符串
- **尾码**: 'E' (0x45)
- **校验和**: 以上数据的累加值1字节
## 3. 初始化和基本控制指令
### 3.1 初始化指令
```bash
# 初始化活塞驱动机构
HE
# 示例OEM通信
# 主机发送: 5B 32 48 45 1A
# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6
# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC
```
### 3.2 枪头操作指令
```bash
# 顶出枪头
RE
# 枪头检测状态报告
Q28 # 返回枪头存在状态0=不存在1=存在)
```
## 4. 移液控制指令
### 4.1 位置控制指令
```bash
# 绝对位置移动(微升)
A[n]E
# 示例移动到位置0
A0E
# 相对抽吸(向上移动)
P[n]E
# 示例抽吸200微升
P200E
# 相对分配(向下移动)
D[n]E
# 示例分配200微升
D200E
```
### 4.2 速度设置指令
```bash
# 设置最高速度0.1ul/秒为单位)
s[n]E
# 示例设置最高速度为2000200ul/秒)
s2000E
# 设置启动速度
b[n]E
# 示例设置启动速度为10010ul/秒)
b100E
# 设置断流速度
c[n]E
# 示例设置断流速度为10010ul/秒)
c100E
# 设置加速度
a[n]E
# 示例设置加速度为30000
a30000E
```
## 5. 液体检测和安全控制指令
### 5.1 吸排液检测控制
```bash
# 开启吸排液检测
f1E # 开启
f0E # 关闭
# 设置空吸门限
$[n]E
# 示例设置空吸门限为4
$4E
# 设置泡沫门限
![n]E
# 示例设置泡沫门限为20
!20E
# 设置堵塞门限
%[n]E
# 示例设置堵塞门限为350
%350E
```
### 5.2 液位检测指令
```bash
# 压力式液位检测
m0E # 设置为压力探测模式
L[n]E # 执行液位检测,[n]为灵敏度(3~40)
k[n]E # 设置检测速度(100~2000)
# 电容式液位检测
m1E # 设置为电容探测模式
```
## 6. 状态查询和报告指令
### 6.1 基本状态查询
```bash
# 查询固件版本
V
# 查询设备状态
Q[n]
# 常用查询参数:
Q01 # 报告加速度
Q02 # 报告启动速度
Q03 # 报告断流速度
Q06 # 报告最大速度
Q08 # 报告节点地址
Q11 # 报告波特率
Q18 # 报告当前位置
Q28 # 报告枪头存在状态
Q29 # 报告校准系数
Q30 # 报告空吸门限
Q31 # 报告堵针门限
Q32 # 报告泡沫门限
```
## 7. 配置和校准指令
### 7.1 校准参数设置
```bash
# 设置校准系数
j[n]E
# 示例设置校准系数为1.04
j1.04E
# 设置补偿偏差
e[n]E
# 示例设置补偿偏差为2.03
e2.03E
# 设置吸头容量
C[n]E
# 示例设置1000ul吸头
C1000E
```
### 7.2 高级控制参数
```bash
# 设置回吸粘度
][n]E
# 示例设置回吸粘度为30
]30E
# 延时控制
M[n]E
# 示例延时1000毫秒
M1000E
```
## 8. 复合操作指令示例
### 8.1 标准移液操作
```bash
# 完整的200ul移液操作
a30000b200c200s2000P200E
# 解析设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行
```
### 8.2 带检测的移液操作
```bash
# 带空吸检测的200ul抽吸
a30000b200c200s2000f1P200f0E
# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行
```
### 8.3 液面检测操作
```bash
# 压力式液面检测
m0k200L5E
# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测
# 电容式液面检测
m1L3E
# 解析:电容模式 + 灵敏度3 + 执行检测
```
## 9. 错误处理
### 9.1 状态字节说明
- **00h**: 无错误
- **01h**: 上次动作未完成
- **02h**: 设备未初始化
- **03h**: 设备过载
- **04h**: 无效指令
- **05h**: 液位探测故障
- **0Dh**: 空吸
- **0Eh**: 堵针
- **10h**: 泡沫
- **11h**: 吸液超过吸头容量
### 9.2 错误查询
```bash
# 查询当前错误状态
Q # 返回状态字节和错误代码
```
## 10. 通信示例
### 10.1 基本通信流程
1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成
2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据
### 10.2 快速指令表
| 操作 | 指令 | 说明 |
|------|------|------|
| 初始化 | `HE` | 初始化设备 |
| 退枪头 | `RE` | 顶出枪头 |
| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 |
| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 |
| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 |
| 压力液面检测 | `m0k200L5E` | pLLD检测 |
| 电容液面检测 | `m1L3E` | cLLD检测 |
## 11. 注意事项
1. **地址限制**: RS485地址不可设为47、69、91
2. **校验和**: 终端调试时不关心校验和OEM通信需要校验
3. **ASCII格式**: 所有命令和参数都使用ASCII字符
4. **执行指令**: 大部分命令需要以'E'结尾才能执行
5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信
6. **波特率设置**: 默认115200可设置为9600

View File

@@ -0,0 +1,162 @@
# 步进电机B系列控制指令详解
## 基本通信参数
- **通信方式**: RS485
- **协议**: Modbus
- **波特率**: 115200 (默认)
- **数据位**: 8位
- **停止位**: 1位
- **校验位**: 无
- **默认站号**: 1 (可设置1-254)
## 支持的功能码
- **03H**: 读取寄存器
- **06H**: 写入单个寄存器
- **10H**: 写入多个寄存器
## 寄存器地址表
### 状态监控寄存器 (只读)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
| 05H | 03H | 电流 | 当前工作电流 (mA) |
### 控制寄存器 (读写)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
### 位置模式寄存器
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
| 12H | 03H/06H/10H | 保留 | - |
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
### 速度模式寄存器
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 60H | 03H/06H/10H | 保留 | - |
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
### 设备参数寄存器
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|------|--------|------|--------|------|
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
| E2H | 03H/06H/10H | 保留 | 0258H | - |
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
### 版本信息寄存器 (只读)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| F0H | 03H | 版本号 | 固件版本信息 |
| F1H-F4H | 03H | 型号 | 产品型号信息 |
## 常用控制指令示例
### 读取电机状态
```
发送: 01 03 00 00 00 01 84 0A
接收: 01 03 02 00 01 79 84
说明: 电机状态为0001H (正在运行)
```
### 读取当前位置
```
发送: 01 03 00 01 00 02 95 CB
接收: 01 03 04 00 19 00 00 2B F4
说明: 当前位置为1638400步 (100圈)
```
### 停止电机
```
发送: 01 10 00 04 00 01 02 00 00 A7 D4
接收: 01 10 00 04 00 01 40 08
说明: 急停指令
```
### 位置模式运动
```
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
接收: 01 10 00 10 00 06 41 CE
说明: 以5000rpm速度运动到1638400步位置
```
### 速度模式 - 正转
```
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
接收: 01 10 00 60 00 04 C1 D4
说明: 以5000rpm速度正转
```
### 速度模式 - 反转
```
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
接收: 01 10 00 60 00 04 C1 D4
说明: 以5000rpm速度反转 (EC78H = -5000)
```
### 设置设备地址
```
发送: 00 06 00 E0 00 02 C9 F1
接收: 00 06 00 E0 00 02 C9 F1
说明: 将设备地址设置为2
```
## 错误码
| 状态码 | 含义 |
|--------|------|
| 0001H | 功能码错误 |
| 0002H | 地址错误 |
| 0003H | 长度错误 |
## CRC校验算法
```c
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
int wCrc = 0x0000FFFF;
byte[] CRC = new byte[2];
for (int i = 0; i < cnt; i++) {
wCrc ^= ((data[i + offset]) & 0xFF);
for (int j = 0; j < 8; j++) {
if ((wCrc & 0x00000001) == 1) {
wCrc >>= 1;
wCrc ^= 0x0000A001;
} else {
wCrc >>= 1;
}
}
}
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
CRC[0] = (byte) (wCrc & 0x000000FF);
return CRC;
}
```
## 注意事项
1. 所有16位数据采用大端序传输
2. 步数计算: 实际步数 = 高位<<16 | 低位
3. 负数使用补码表示
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
5. 光电开关需使用NPN开漏型
6. 限位开关: LF正向, LB反向

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,285 @@
# LaiYu_Liquid 液体处理工作站 - 生产就绪版本
## 概述
LaiYu_Liquid 是一个完全集成到 UniLabOS 系统的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
## 系统组成
### 硬件组件
- **XYZ三轴运动平台**: 3个RS485步进电机驱动地址X轴=0x01, Y轴=0x02, Z轴=0x03
- **SOPA气动式移液器**: RS485总线控制支持精密液体处理操作
- **通信接口**: RS485转USB模块默认波特率115200
- **机械结构**: 稳固工作台面支持离心管架、96孔板等标准实验耗材
### 软件架构
- **驱动层**: 底层硬件通信驱动支持RS485协议
- **控制层**: 高级控制逻辑和坐标系管理
- **抽象层**: 完全符合UniLabOS标准的液体处理接口
- **资源层**: 标准化的实验器具和耗材管理
## 🎯 生产就绪组件
### ✅ 核心驱动程序 (`drivers/`)
- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
- 支持液体吸取、分配、检测
- 完整的错误处理和状态管理
- 生产级别的通信协议实现
- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
- 精确的位置控制和运动规划
- 安全限位和错误检测
- 高性能运动控制算法
### ✅ 高级控制器 (`controllers/`)
- **`pipette_controller.py`** - 移液控制器
- 封装高级液体处理功能
- 支持多种液体类型和处理参数
- 智能错误恢复机制
- **`xyz_controller.py`** - XYZ运动控制器
- 坐标系管理和转换
- 运动路径优化
- 安全运动控制
### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
- **完整的液体处理抽象接口**
- **标准化的资源管理系统**
- **与PyLabRobot兼容的后端实现**
- **生产级别的错误处理和日志记录**
### ✅ 资源管理系统
- **`laiyu_liquid_res.py`** - 标准化资源定义
- 96孔板、离心管架、枪头架等标准器具
- 自动化的资源创建和配置函数
- 与工作台布局的完美集成
### ✅ 配置管理 (`config/`)
- **`config/deck.json`** - 工作台布局配置
- 精确的空间定义和槽位管理
- 支持多种实验器具的标准化放置
- 可扩展的配置架构
- **`__init__.py`** - 模块集成和导出
- 完整的API导出和版本管理
- 依赖检查和安装验证
- 专业的模块信息展示
### ✅ 可视化支持
- **`rviz_backend.py`** - RViz可视化后端
- 实时运动状态可视化
- 液体处理过程监控
- 与ROS系统的无缝集成
## 🚀 核心功能特性
### 液体处理能力
- **精密体积控制**: 支持1-1000μL精确分配
- **多种液体类型**: 水性、有机溶剂、粘稠液体等
- **智能检测**: 液位检测、气泡检测、堵塞检测
- **自动化流程**: 完整的吸取-转移-分配工作流
### 运动控制系统
- **三轴精密定位**: 微米级精度控制
- **路径优化**: 智能运动规划和碰撞避免
- **安全机制**: 限位保护、紧急停止、错误恢复
- **坐标系管理**: 工作坐标与机械坐标的自动转换
### 资源管理
- **标准化器具**: 支持96孔板、离心管架、枪头架等
- **状态跟踪**: 实时监控液体体积、枪头状态等
- **自动配置**: 基于JSON的灵活配置系统
- **扩展性**: 易于添加新的器具类型
## 📁 目录结构
```
LaiYu_Liquid/
├── __init__.py # 模块初始化和API导出
├── readme.md # 本文档
├── rviz_backend.py # RViz可视化后端
├── backend/ # 后端驱动模块
│ ├── __init__.py
│ └── laiyu_backend.py # PyLabRobot兼容后端
├── core/ # 核心模块
│ ├── core/
│ │ └── LaiYu_Liquid.py # 主设备类
│ ├── abstract_protocol.py # 抽象协议
│ └── laiyu_liquid_res.py # 设备资源定义
├── config/ # 配置文件目录
│ └── deck.json # 工作台布局配置
├── controllers/ # 高级控制器
│ ├── __init__.py
│ ├── pipette_controller.py # 移液控制器
│ └── xyz_controller.py # XYZ运动控制器
├── docs/ # 技术文档
│ ├── SOPA气动式移液器RS485控制指令.md
│ ├── 步进电机控制指令.md
│ └── hardware/ # 硬件相关文档
├── drivers/ # 底层驱动程序
│ ├── __init__.py
│ ├── sopa_pipette_driver.py # SOPA移液器驱动
│ └── xyz_stepper_driver.py # XYZ步进电机驱动
└── tests/ # 测试文件
```
## 🔧 快速开始
### 1. 安装和验证
```python
# 验证模块安装
from unilabos.devices.laiyu_liquid import (
LaiYuLiquid,
LaiYuLiquidConfig,
create_quick_setup,
print_module_info
)
# 查看模块信息
print_module_info()
# 快速创建默认资源
resources = create_quick_setup()
print(f"已创建 {len(resources)} 个资源")
```
### 2. 基本使用示例
```python
from unilabos.devices.LaiYu_Liquid import (
create_quick_setup,
create_96_well_plate,
create_laiyu_backend
)
# 快速创建默认资源
resources = create_quick_setup()
print(f"创建了以下资源: {list(resources.keys())}")
# 创建96孔板
plate_96 = create_96_well_plate("test_plate")
print(f"96孔板包含 {len(plate_96.children)} 个孔位")
# 创建后端实例用于PyLabRobot集成
backend = create_laiyu_backend("LaiYu_Device")
print(f"后端设备: {backend.name}")
```
### 3. 后端驱动使用
```python
from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
# 创建后端实例
backend = create_laiyu_backend("LaiYu_Liquid_Station")
# 连接设备
await backend.connect()
# 设备归位
await backend.home_device()
# 获取设备状态
status = await backend.get_status()
print(f"设备状态: {status}")
# 断开连接
await backend.disconnect()
```
### 4. 资源管理示例
```python
from unilabos.devices.LaiYu_Liquid import (
create_centrifuge_tube_rack,
create_tip_rack,
load_deck_config
)
# 加载工作台配置
deck_config = load_deck_config()
print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
# 创建不同类型的资源
tube_rack = create_centrifuge_tube_rack("sample_rack")
tip_rack = create_tip_rack("tip_rack_200ul")
print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
```
## 🔍 技术架构
### 坐标系统
- **机械坐标**: 基于步进电机的原始坐标系统
- **工作坐标**: 用户友好的实验室坐标系统
- **自动转换**: 透明的坐标系转换和校准
### 通信协议
- **RS485总线**: 高可靠性工业通信标准
- **Modbus协议**: 标准化的设备通信协议
- **错误检测**: 完整的通信错误检测和恢复
### 安全机制
- **限位保护**: 硬件和软件双重限位保护
- **紧急停止**: 即时停止所有运动和操作
- **状态监控**: 实时设备状态监控和报警
## 🧪 验证和测试
### 功能验证
```python
# 验证模块安装
from unilabos.devices.laiyu_liquid import validate_installation
validate_installation()
# 查看模块信息
from unilabos.devices.laiyu_liquid import print_module_info
print_module_info()
```
### 硬件连接测试
```python
# 测试SOPA移液器连接
from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
pipette = SOPAPipette(config)
success = pipette.connect()
print(f"SOPA连接状态: {'成功' if success else '失败'}")
```
## 📚 维护和支持
### 日志记录
- **结构化日志**: 使用Python logging模块的专业日志记录
- **错误追踪**: 详细的错误信息和堆栈跟踪
- **性能监控**: 操作时间和性能指标记录
### 配置管理
- **JSON配置**: 灵活的JSON格式配置文件
- **参数验证**: 自动配置参数验证和错误提示
- **热重载**: 支持配置文件的动态重载
### 扩展性
- **模块化设计**: 易于扩展和定制的模块化架构
- **插件接口**: 支持第三方插件和扩展
- **API兼容**: 向后兼容的API设计
## 📞 技术支持
### 常见问题
1. **串口权限问题**: 确保用户有串口访问权限
2. **依赖库安装**: 使用pip安装所需的Python库
3. **设备连接**: 检查RS485适配器和设备地址配置
### 联系方式
- **技术文档**: 查看UniLabOS官方文档
- **问题反馈**: 通过GitHub Issues提交问题
- **社区支持**: 加入UniLabOS开发者社区
---
**LaiYu_Liquid v1.0.0** - 生产就绪的液体处理工作站集成模块
© 2024 UniLabOS Project. All rights reserved.

View File

@@ -0,0 +1,30 @@
"""
LaiYu_Liquid 驱动程序模块
该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序
- SOPA移液器驱动程序
- XYZ步进电机驱动程序
"""
# SOPA移液器驱动程序导入
from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode
# XYZ步进电机驱动程序导入
from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus
__all__ = [
# SOPA移液器
"SOPAPipette",
"SOPAConfig",
"SOPAStatusCode",
# XYZ步进电机
"StepperMotorDriver",
"XYZStepperController",
"MotorAxis",
"MotorStatus",
]
__version__ = "1.0.0"
__author__ = "LaiYu_Liquid Driver Team"
__description__ = "LaiYu_Liquid 硬件驱动程序集合"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,663 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XYZ三轴步进电机B系列驱动程序
支持RS485通信Modbus协议
"""
import serial
import struct
import time
import logging
from typing import Optional, Tuple, Dict, Any
from enum import Enum
from dataclasses import dataclass
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class MotorAxis(Enum):
"""电机轴枚举"""
X = 1
Y = 2
Z = 3
class MotorStatus(Enum):
"""电机状态枚举"""
STANDBY = 0x0000 # 待机/到位
RUNNING = 0x0001 # 运行中
COLLISION_STOP = 0x0002 # 碰撞停
FORWARD_LIMIT_STOP = 0x0003 # 正光电停
REVERSE_LIMIT_STOP = 0x0004 # 反光电停
class ModbusFunction(Enum):
"""Modbus功能码"""
READ_HOLDING_REGISTERS = 0x03
WRITE_SINGLE_REGISTER = 0x06
WRITE_MULTIPLE_REGISTERS = 0x10
@dataclass
class MotorPosition:
"""电机位置信息"""
steps: int
speed: int
current: int
status: MotorStatus
class ModbusException(Exception):
"""Modbus通信异常"""
pass
class StepperMotorDriver:
"""步进电机驱动器基类"""
# 寄存器地址常量
REG_STATUS = 0x00
REG_POSITION_HIGH = 0x01
REG_POSITION_LOW = 0x02
REG_ACTUAL_SPEED = 0x03
REG_EMERGENCY_STOP = 0x04
REG_CURRENT = 0x05
REG_ENABLE = 0x06
REG_PWM_OUTPUT = 0x07
REG_ZERO_SINGLE = 0x0E
REG_ZERO_COMMAND = 0x0F
# 位置模式寄存器
REG_TARGET_POSITION_HIGH = 0x10
REG_TARGET_POSITION_LOW = 0x11
REG_POSITION_SPEED = 0x13
REG_POSITION_ACCELERATION = 0x14
REG_POSITION_PRECISION = 0x15
# 速度模式寄存器
REG_SPEED_MODE_SPEED = 0x61
REG_SPEED_MODE_ACCELERATION = 0x62
# 设备参数寄存器
REG_DEVICE_ADDRESS = 0xE0
REG_DEFAULT_SPEED = 0xE7
REG_DEFAULT_ACCELERATION = 0xE8
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
"""
初始化步进电机驱动器
Args:
port: 串口端口名
baudrate: 波特率
timeout: 通信超时时间
"""
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.serial_conn: Optional[serial.Serial] = None
def connect(self) -> bool:
"""
建立串口连接
Returns:
连接是否成功
"""
try:
self.serial_conn = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout
)
logger.info(f"已连接到串口: {self.port}")
return True
except Exception as e:
logger.error(f"串口连接失败: {e}")
return False
def disconnect(self) -> None:
"""关闭串口连接"""
if self.serial_conn and self.serial_conn.is_open:
self.serial_conn.close()
logger.info("串口连接已关闭")
def __enter__(self):
"""上下文管理器入口"""
if self.connect():
return self
raise ModbusException("无法建立串口连接")
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器出口"""
self.disconnect()
@staticmethod
def calculate_crc(data: bytes) -> bytes:
"""
计算Modbus CRC校验码
Args:
data: 待校验的数据
Returns:
CRC校验码 (2字节)
"""
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001:
crc >>= 1
crc ^= 0xA001
else:
crc >>= 1
return struct.pack('<H', crc)
def _send_command(self, slave_addr: int, data: bytes) -> bytes:
"""
发送Modbus命令并接收响应
Args:
slave_addr: 从站地址
data: 命令数据
Returns:
响应数据
Raises:
ModbusException: 通信异常
"""
if not self.serial_conn or not self.serial_conn.is_open:
raise ModbusException("串口未连接")
# 构建完整命令
command = bytes([slave_addr]) + data
crc = self.calculate_crc(command)
full_command = command + crc
# 清空接收缓冲区
self.serial_conn.reset_input_buffer()
# 发送命令
self.serial_conn.write(full_command)
logger.debug(f"发送命令: {' '.join(f'{b:02X}' for b in full_command)}")
# 等待响应
time.sleep(0.01) # 短暂延时
# 读取响应
response = self.serial_conn.read(256) # 最大读取256字节
if not response:
raise ModbusException("未收到响应")
logger.debug(f"接收响应: {' '.join(f'{b:02X}' for b in response)}")
# 验证CRC
if len(response) < 3:
raise ModbusException("响应数据长度不足")
data_part = response[:-2]
received_crc = response[-2:]
calculated_crc = self.calculate_crc(data_part)
if received_crc != calculated_crc:
raise ModbusException(f"CRC校验失败{response}")
return response
def read_registers(self, slave_addr: int, start_addr: int, count: int) -> list:
"""
读取保持寄存器
Args:
slave_addr: 从站地址
start_addr: 起始地址
count: 寄存器数量
Returns:
寄存器值列表
"""
data = struct.pack('>BHH', ModbusFunction.READ_HOLDING_REGISTERS.value, start_addr, count)
response = self._send_command(slave_addr, data)
if len(response) < 5:
raise ModbusException("响应长度不足")
if response[1] != ModbusFunction.READ_HOLDING_REGISTERS.value:
raise ModbusException(f"功能码错误: {response[1]:02X}")
byte_count = response[2]
values = []
for i in range(0, byte_count, 2):
value = struct.unpack('>H', response[3+i:5+i])[0]
values.append(value)
return values
def write_single_register(self, slave_addr: int, addr: int, value: int) -> bool:
"""
写入单个寄存器
Args:
slave_addr: 从站地址
addr: 寄存器地址
value: 寄存器值
Returns:
写入是否成功
"""
data = struct.pack('>BHH', ModbusFunction.WRITE_SINGLE_REGISTER.value, addr, value)
response = self._send_command(slave_addr, data)
return len(response) >= 8 and response[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
def write_multiple_registers(self, slave_addr: int, start_addr: int, values: list) -> bool:
"""
写入多个寄存器
Args:
slave_addr: 从站地址
start_addr: 起始地址
values: 寄存器值列表
Returns:
写入是否成功
"""
byte_count = len(values) * 2
data = struct.pack('>BHHB', ModbusFunction.WRITE_MULTIPLE_REGISTERS.value,
start_addr, len(values), byte_count)
for value in values:
data += struct.pack('>H', value)
response = self._send_command(slave_addr, data)
return len(response) >= 8 and response[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
class XYZStepperController(StepperMotorDriver):
"""XYZ三轴步进电机控制器"""
# 电机配置常量
STEPS_PER_REVOLUTION = 16384 # 每圈步数
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
"""
初始化XYZ三轴步进电机控制器
Args:
port: 串口端口名
baudrate: 波特率
timeout: 通信超时时间
"""
super().__init__(port, baudrate, timeout)
self.axis_addresses = {
MotorAxis.X: 1,
MotorAxis.Y: 2,
MotorAxis.Z: 3
}
def degrees_to_steps(self, degrees: float) -> int:
"""
将角度转换为步数
Args:
degrees: 角度值
Returns:
对应的步数
"""
return int(degrees * self.STEPS_PER_REVOLUTION / 360.0)
def steps_to_degrees(self, steps: int) -> float:
"""
将步数转换为角度
Args:
steps: 步数
Returns:
对应的角度值
"""
return steps * 360.0 / self.STEPS_PER_REVOLUTION
def revolutions_to_steps(self, revolutions: float) -> int:
"""
将圈数转换为步数
Args:
revolutions: 圈数
Returns:
对应的步数
"""
return int(revolutions * self.STEPS_PER_REVOLUTION)
def steps_to_revolutions(self, steps: int) -> float:
"""
将步数转换为圈数
Args:
steps: 步数
Returns:
对应的圈数
"""
return steps / self.STEPS_PER_REVOLUTION
def get_motor_status(self, axis: MotorAxis) -> MotorPosition:
"""
获取电机状态信息
Args:
axis: 电机轴
Returns:
电机位置信息
"""
addr = self.axis_addresses[axis]
# 读取状态、位置、速度、电流
values = self.read_registers(addr, self.REG_STATUS, 6)
status = MotorStatus(values[0])
position_high = values[1]
position_low = values[2]
speed = values[3]
current = values[5]
# 合并32位位置
position = (position_high << 16) | position_low
# 处理有符号数
if position > 0x7FFFFFFF:
position -= 0x100000000
return MotorPosition(position, speed, current, status)
def emergency_stop(self, axis: MotorAxis) -> bool:
"""
紧急停止电机
Args:
axis: 电机轴
Returns:
操作是否成功
"""
addr = self.axis_addresses[axis]
return self.write_single_register(addr, self.REG_EMERGENCY_STOP, 0x0000)
def enable_motor(self, axis: MotorAxis, enable: bool = True) -> bool:
"""
使能/失能电机
Args:
axis: 电机轴
enable: True为使能False为失能
Returns:
操作是否成功
"""
addr = self.axis_addresses[axis]
value = 0x0001 if enable else 0x0000
return self.write_single_register(addr, self.REG_ENABLE, value)
def move_to_position(self, axis: MotorAxis, position: int, speed: int = 5000,
acceleration: int = 1000, precision: int = 100) -> bool:
"""
移动到指定位置
Args:
axis: 电机轴
position: 目标位置(步数)
speed: 运行速度(rpm)
acceleration: 加速度(rpm/s)
precision: 到位精度
Returns:
操作是否成功
"""
addr = self.axis_addresses[axis]
# 处理32位位置
if position < 0:
position += 0x100000000
position_high = (position >> 16) & 0xFFFF
position_low = position & 0xFFFF
values = [
position_high, # 目标位置高位
position_low, # 目标位置低位
0x0000, # 保留
speed, # 速度
acceleration, # 加速度
precision # 精度
]
return self.write_multiple_registers(addr, self.REG_TARGET_POSITION_HIGH, values)
def set_speed_mode(self, axis: MotorAxis, speed: int, acceleration: int = 1000) -> bool:
"""
设置速度模式运行
Args:
axis: 电机轴
speed: 运行速度(rpm),正值正转,负值反转
acceleration: 加速度(rpm/s)
Returns:
操作是否成功
"""
addr = self.axis_addresses[axis]
# 处理负数
if speed < 0:
speed = 0x10000 + speed # 补码表示
values = [0x0000, speed, acceleration, 0x0000]
return self.write_multiple_registers(addr, 0x60, values)
def home_axis(self, axis: MotorAxis) -> bool:
"""
轴归零操作
Args:
axis: 电机轴
Returns:
操作是否成功
"""
addr = self.axis_addresses[axis]
return self.write_single_register(addr, self.REG_ZERO_SINGLE, 0x0001)
def wait_for_completion(self, axis: MotorAxis, timeout: float = 30.0) -> bool:
"""
等待电机运动完成
Args:
axis: 电机轴
timeout: 超时时间(秒)
Returns:
是否在超时前完成
"""
start_time = time.time()
while time.time() - start_time < timeout:
status = self.get_motor_status(axis)
if status.status == MotorStatus.STANDBY:
return True
time.sleep(0.1)
logger.warning(f"{axis.name}轴运动超时")
return False
def move_xyz(self, x: Optional[int] = None, y: Optional[int] = None, z: Optional[int] = None,
speed: int = 5000, acceleration: int = 1000) -> Dict[MotorAxis, bool]:
"""
同时控制XYZ轴移动
Args:
x: X轴目标位置
y: Y轴目标位置
z: Z轴目标位置
speed: 运行速度
acceleration: 加速度
Returns:
各轴操作结果字典
"""
results = {}
if x is not None:
results[MotorAxis.X] = self.move_to_position(MotorAxis.X, x, speed, acceleration)
if y is not None:
results[MotorAxis.Y] = self.move_to_position(MotorAxis.Y, y, speed, acceleration)
if z is not None:
results[MotorAxis.Z] = self.move_to_position(MotorAxis.Z, z, speed, acceleration)
return results
def move_xyz_degrees(self, x_deg: Optional[float] = None, y_deg: Optional[float] = None,
z_deg: Optional[float] = None, speed: int = 5000,
acceleration: int = 1000) -> Dict[MotorAxis, bool]:
"""
使用角度值同时移动多个轴到指定位置
Args:
x_deg: X轴目标角度
y_deg: Y轴目标角度
z_deg: Z轴目标角度
speed: 移动速度
acceleration: 加速度
Returns:
各轴移动操作结果
"""
# 将角度转换为步数
x_steps = self.degrees_to_steps(x_deg) if x_deg is not None else None
y_steps = self.degrees_to_steps(y_deg) if y_deg is not None else None
z_steps = self.degrees_to_steps(z_deg) if z_deg is not None else None
return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
def move_xyz_revolutions(self, x_rev: Optional[float] = None, y_rev: Optional[float] = None,
z_rev: Optional[float] = None, speed: int = 5000,
acceleration: int = 1000) -> Dict[MotorAxis, bool]:
"""
使用圈数值同时移动多个轴到指定位置
Args:
x_rev: X轴目标圈数
y_rev: Y轴目标圈数
z_rev: Z轴目标圈数
speed: 移动速度
acceleration: 加速度
Returns:
各轴移动操作结果
"""
# 将圈数转换为步数
x_steps = self.revolutions_to_steps(x_rev) if x_rev is not None else None
y_steps = self.revolutions_to_steps(y_rev) if y_rev is not None else None
z_steps = self.revolutions_to_steps(z_rev) if z_rev is not None else None
return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
def move_to_position_degrees(self, axis: MotorAxis, degrees: float, speed: int = 5000,
acceleration: int = 1000, precision: int = 100) -> bool:
"""
使用角度值移动单个轴到指定位置
Args:
axis: 电机轴
degrees: 目标角度(度)
speed: 移动速度
acceleration: 加速度
precision: 精度
Returns:
移动操作是否成功
"""
steps = self.degrees_to_steps(degrees)
return self.move_to_position(axis, steps, speed, acceleration, precision)
def move_to_position_revolutions(self, axis: MotorAxis, revolutions: float, speed: int = 5000,
acceleration: int = 1000, precision: int = 100) -> bool:
"""
使用圈数值移动单个轴到指定位置
Args:
axis: 电机轴
revolutions: 目标圈数
speed: 移动速度
acceleration: 加速度
precision: 精度
Returns:
移动操作是否成功
"""
steps = self.revolutions_to_steps(revolutions)
return self.move_to_position(axis, steps, speed, acceleration, precision)
def stop_all_axes(self) -> Dict[MotorAxis, bool]:
"""
紧急停止所有轴
Returns:
各轴停止结果字典
"""
results = {}
for axis in MotorAxis:
results[axis] = self.emergency_stop(axis)
return results
def enable_all_axes(self, enable: bool = True) -> Dict[MotorAxis, bool]:
"""
使能/失能所有轴
Args:
enable: True为使能False为失能
Returns:
各轴操作结果字典
"""
results = {}
for axis in MotorAxis:
results[axis] = self.enable_motor(axis, enable)
return results
def get_all_positions(self) -> Dict[MotorAxis, MotorPosition]:
"""
获取所有轴的位置信息
Returns:
各轴位置信息字典
"""
positions = {}
for axis in MotorAxis:
positions[axis] = self.get_motor_status(axis)
return positions
def home_all_axes(self) -> Dict[MotorAxis, bool]:
"""
所有轴归零
Returns:
各轴归零结果字典
"""
results = {}
for axis in MotorAxis:
results[axis] = self.home_axis(axis)
return results

View File

@@ -0,0 +1,218 @@
import asyncio
import collections
import contextlib
import json
import time
from typing import Any, List, Dict, Optional, TypedDict, Union, Sequence, Iterator, Literal
from pylabrobot.liquid_handling import (
LiquidHandlerBackend,
Pickup,
SingleChannelAspiration,
Drop,
SingleChannelDispense,
PickupTipRack,
DropTipRack,
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
)
from pylabrobot.liquid_handling.standard import (
MultiHeadAspirationContainer,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
ResourcePickup,
ResourceMove,
ResourceDrop,
)
from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
class TransformXYZDeck(Deck):
"""Laiyu 的专用 Deck 类,继承自 Deck。
该类定义了 Laiyu 的工作台布局和槽位信息。
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float):
super().__init__(name, size_x, size_y, size_z)
self.name = name
class TransformXYZBackend(LiquidHandlerBackend):
def __init__(self, name: str, host: str, port: int, timeout: float):
super().__init__()
self.host = host
self.port = port
self.timeout = timeout
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
def __init__(self, name: str, channel_num: int):
super().__init__(channel_num)
self.name = name
class TransformXYZContainer(Plate, TipRack):
"""Laiyu 的专用 Container 类,继承自 Plate和TipRack。
该类定义了 Laiyu 的工作台布局和槽位信息。
"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str,
ordering: collections.OrderedDict,
model: Optional[str] = None,
):
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
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)
return data
class TransformXYZHandler(LiquidHandlerAbstract):
support_touch_tip = False
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs):
# Handle case where deck is passed as a dict (from serialization)
if isinstance(deck, dict):
# Try to create a TransformXYZDeck from the dict
if 'name' in deck and 'size_x' in deck and 'size_y' in deck and 'size_z' in deck:
deck = TransformXYZDeck(
name=deck['name'],
size_x=deck.get('size_x', 100),
size_y=deck.get('size_y', 100),
size_z=deck.get('size_z', 100)
)
else:
# Fallback: create a basic deck
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
if simulator:
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num)
else:
self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout)
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
async def add_liquid(
self,
asp_vols: Union[List[float], float],
dis_vols: Union[List[float], float],
reagent_sources: Sequence[Container],
targets: Sequence[Container],
*,
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Optional[List[Coordinate]] = None,
liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None,
spread: Optional[Literal["wide", "tight", "custom"]] = "wide",
is_96_well: bool = False,
delays: Optional[List[int]] = None,
mix_time: Optional[int] = None,
mix_vol: Optional[int] = None,
mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None,
none_keys: List[str] = [],
):
pass
async def aspirate(
self,
resources: Sequence[Container],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Optional[List[Coordinate]] = None,
liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None,
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
pass
async def dispense(
self,
resources: Sequence[Container],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Optional[List[Coordinate]] = None,
liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None,
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
pass
async def drop_tips(
self,
tip_spots: Sequence[Union[TipSpot, Trash]],
use_channels: Optional[List[int]] = None,
offsets: Optional[List[Coordinate]] = None,
allow_nonzero_volume: bool = False,
**backend_kwargs,
):
pass
async def mix(
self,
targets: Sequence[Container],
mix_time: int = None,
mix_vol: Optional[int] = None,
height_to_bottom: Optional[float] = None,
offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None,
none_keys: List[str] = [],
):
pass
async def pick_up_tips(
self,
tip_spots: List[TipSpot],
use_channels: Optional[List[int]] = None,
offsets: Optional[List[Coordinate]] = None,
**backend_kwargs,
):
pass
async def transfer_liquid(
self,
sources: Sequence[Container],
targets: Sequence[Container],
tip_racks: Sequence[TipRack],
*,
use_channels: Optional[List[int]] = None,
asp_vols: Union[List[float], float],
dis_vols: Union[List[float], float],
asp_flow_rates: Optional[List[Optional[float]]] = None,
dis_flow_rates: Optional[List[Optional[float]]] = None,
offsets: Optional[List[Coordinate]] = None,
touch_tip: bool = False,
liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None,
spread: Literal["wide", "tight", "custom"] = "wide",
is_96_well: bool = False,
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
mix_times: Optional[List[int]] = None,
mix_vol: Optional[int] = None,
mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None,
delays: Optional[List[int]] = None,
none_keys: List[str] = [],
):
pass

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LaiYu液体处理设备测试模块
该模块包含LaiYu液体处理设备的测试用例
- test_deck_config.py: 工作台配置测试
作者: UniLab团队
版本: 2.0.0
"""
__all__ = []

View File

@@ -0,0 +1,315 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试脚本验证更新后的deck配置是否正常工作
"""
import sys
import os
import json
# 添加项目根目录到Python路径
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, project_root)
def test_config_loading():
"""测试配置文件加载功能"""
print("=" * 50)
print("测试配置文件加载功能")
print("=" * 50)
try:
# 直接测试配置文件加载
config_path = os.path.join(os.path.dirname(__file__), "controllers", "deckconfig.json")
fallback_path = os.path.join(os.path.dirname(__file__), "config", "deck.json")
config = None
config_source = ""
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
config_source = "config/deckconfig.json"
elif os.path.exists(fallback_path):
with open(fallback_path, 'r', encoding='utf-8') as f:
config = json.load(f)
config_source = "config/deck.json"
else:
print("❌ 配置文件不存在")
return False
print(f"✅ 配置文件加载成功: {config_source}")
print(f" - 甲板尺寸: {config.get('size_x', 'N/A')} x {config.get('size_y', 'N/A')} x {config.get('size_z', 'N/A')}")
print(f" - 子模块数量: {len(config.get('children', []))}")
# 检查各个模块是否存在
modules = config.get('children', [])
module_types = [module.get('type') for module in modules]
module_names = [module.get('name') for module in modules]
print(f" - 模块类型: {', '.join(set(filter(None, module_types)))}")
print(f" - 模块名称: {', '.join(filter(None, module_names))}")
return config
except Exception as e:
print(f"❌ 配置文件加载失败: {e}")
return None
def test_module_coordinates(config):
"""测试各模块的坐标信息"""
print("\n" + "=" * 50)
print("测试模块坐标信息")
print("=" * 50)
if not config:
print("❌ 配置为空,无法测试")
return False
modules = config.get('children', [])
for module in modules:
module_name = module.get('name', '未知模块')
module_type = module.get('type', '未知类型')
position = module.get('position', {})
size = module.get('size', {})
print(f"\n模块: {module_name} ({module_type})")
print(f" - 位置: ({position.get('x', 0)}, {position.get('y', 0)}, {position.get('z', 0)})")
print(f" - 尺寸: {size.get('x', 0)} x {size.get('y', 0)} x {size.get('z', 0)}")
# 检查孔位信息
wells = module.get('wells', [])
if wells:
print(f" - 孔位数量: {len(wells)}")
# 显示前几个和后几个孔位的坐标
sample_wells = wells[:3] + wells[-3:] if len(wells) > 6 else wells
for well in sample_wells:
well_id = well.get('id', '未知')
well_pos = well.get('position', {})
print(f" {well_id}: ({well_pos.get('x', 0)}, {well_pos.get('y', 0)}, {well_pos.get('z', 0)})")
else:
print(f" - 无孔位信息")
return True
def test_coordinate_ranges(config):
"""测试坐标范围的合理性"""
print("\n" + "=" * 50)
print("测试坐标范围合理性")
print("=" * 50)
if not config:
print("❌ 配置为空,无法测试")
return False
deck_size = {
'x': config.get('size_x', 340),
'y': config.get('size_y', 250),
'z': config.get('size_z', 160)
}
print(f"甲板尺寸: {deck_size['x']} x {deck_size['y']} x {deck_size['z']}")
modules = config.get('children', [])
all_coordinates = []
for module in modules:
module_name = module.get('name', '未知模块')
wells = module.get('wells', [])
for well in wells:
well_pos = well.get('position', {})
x, y, z = well_pos.get('x', 0), well_pos.get('y', 0), well_pos.get('z', 0)
all_coordinates.append((x, y, z, f"{module_name}:{well.get('id', '未知')}"))
if not all_coordinates:
print("❌ 没有找到任何坐标信息")
return False
# 计算坐标范围
x_coords = [coord[0] for coord in all_coordinates]
y_coords = [coord[1] for coord in all_coordinates]
z_coords = [coord[2] for coord in all_coordinates]
x_range = (min(x_coords), max(x_coords))
y_range = (min(y_coords), max(y_coords))
z_range = (min(z_coords), max(z_coords))
print(f"X坐标范围: {x_range[0]:.2f} ~ {x_range[1]:.2f}")
print(f"Y坐标范围: {y_range[0]:.2f} ~ {y_range[1]:.2f}")
print(f"Z坐标范围: {z_range[0]:.2f} ~ {z_range[1]:.2f}")
# 检查是否超出甲板范围
issues = []
if x_range[1] > deck_size['x']:
issues.append(f"X坐标超出甲板范围: {x_range[1]} > {deck_size['x']}")
if y_range[1] > deck_size['y']:
issues.append(f"Y坐标超出甲板范围: {y_range[1]} > {deck_size['y']}")
if z_range[1] > deck_size['z']:
issues.append(f"Z坐标超出甲板范围: {z_range[1]} > {deck_size['z']}")
if x_range[0] < 0:
issues.append(f"X坐标为负值: {x_range[0]}")
if y_range[0] < 0:
issues.append(f"Y坐标为负值: {y_range[0]}")
if z_range[0] < 0:
issues.append(f"Z坐标为负值: {z_range[0]}")
if issues:
print("⚠️ 发现坐标问题:")
for issue in issues:
print(f" - {issue}")
return False
else:
print("✅ 所有坐标都在合理范围内")
return True
def test_well_spacing(config):
"""测试孔位间距的一致性"""
print("\n" + "=" * 50)
print("测试孔位间距一致性")
print("=" * 50)
if not config:
print("❌ 配置为空,无法测试")
return False
modules = config.get('children', [])
for module in modules:
module_name = module.get('name', '未知模块')
module_type = module.get('type', '未知类型')
wells = module.get('wells', [])
if len(wells) < 2:
continue
print(f"\n模块: {module_name} ({module_type})")
# 计算相邻孔位的间距
spacings_x = []
spacings_y = []
# 按行列排序孔位
wells_by_row = {}
for well in wells:
well_id = well.get('id', '')
if len(well_id) >= 3: # 如A01格式
row = well_id[0]
col = int(well_id[1:])
if row not in wells_by_row:
wells_by_row[row] = {}
wells_by_row[row][col] = well
# 计算同行相邻孔位的X间距
for row, cols in wells_by_row.items():
sorted_cols = sorted(cols.keys())
for i in range(len(sorted_cols) - 1):
col1, col2 = sorted_cols[i], sorted_cols[i + 1]
if col2 == col1 + 1: # 相邻列
pos1 = cols[col1].get('position', {})
pos2 = cols[col2].get('position', {})
spacing = abs(pos2.get('x', 0) - pos1.get('x', 0))
spacings_x.append(spacing)
# 计算同列相邻孔位的Y间距
cols_by_row = {}
for well in wells:
well_id = well.get('id', '')
if len(well_id) >= 3:
row = ord(well_id[0]) - ord('A')
col = int(well_id[1:])
if col not in cols_by_row:
cols_by_row[col] = {}
cols_by_row[col][row] = well
for col, rows in cols_by_row.items():
sorted_rows = sorted(rows.keys())
for i in range(len(sorted_rows) - 1):
row1, row2 = sorted_rows[i], sorted_rows[i + 1]
if row2 == row1 + 1: # 相邻行
pos1 = rows[row1].get('position', {})
pos2 = rows[row2].get('position', {})
spacing = abs(pos2.get('y', 0) - pos1.get('y', 0))
spacings_y.append(spacing)
# 检查间距一致性
if spacings_x:
avg_x = sum(spacings_x) / len(spacings_x)
max_diff_x = max(abs(s - avg_x) for s in spacings_x)
print(f" - X方向平均间距: {avg_x:.2f}mm, 最大偏差: {max_diff_x:.2f}mm")
if spacings_y:
avg_y = sum(spacings_y) / len(spacings_y)
max_diff_y = max(abs(s - avg_y) for s in spacings_y)
print(f" - Y方向平均间距: {avg_y:.2f}mm, 最大偏差: {max_diff_y:.2f}mm")
return True
def main():
"""主测试函数"""
print("LaiYu液体处理设备配置测试")
print("测试时间:", os.popen('date').read().strip())
# 运行所有测试
tests = [
("配置文件加载", test_config_loading),
]
config = None
results = []
for test_name, test_func in tests:
try:
if test_name == "配置文件加载":
result = test_func()
config = result if result else None
results.append((test_name, bool(result)))
else:
result = test_func(config)
results.append((test_name, result))
except Exception as e:
print(f"❌ 测试 {test_name} 执行失败: {e}")
results.append((test_name, False))
# 如果配置加载成功,运行其他测试
if config:
additional_tests = [
("模块坐标信息", test_module_coordinates),
("坐标范围合理性", test_coordinate_ranges),
("孔位间距一致性", test_well_spacing)
]
for test_name, test_func in additional_tests:
try:
result = test_func(config)
results.append((test_name, result))
except Exception as e:
print(f"❌ 测试 {test_name} 执行失败: {e}")
results.append((test_name, False))
# 输出测试总结
print("\n" + "=" * 50)
print("测试总结")
print("=" * 50)
passed = sum(1 for _, result in results if result)
total = len(results)
for test_name, result in results:
status = "✅ 通过" if result else "❌ 失败"
print(f" {test_name}: {status}")
print(f"\n总计: {passed}/{total} 个测试通过")
if passed == total:
print("🎉 所有测试通过!配置更新成功。")
return True
else:
print("⚠️ 部分测试失败,需要进一步检查。")
return False
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)

View File

@@ -7,6 +7,8 @@ from collections import Counter
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast
from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod
from pylabrobot.liquid_handling.standard import GripDirection
from pylabrobot.resources import (
@@ -29,11 +31,16 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class LiquidHandlerMiddleware(LiquidHandler):
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8):
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs):
self._simulator = simulator
self.channel_num = channel_num
joint_config = kwargs.get("joint_config", None)
if simulator:
self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num)
if joint_config:
self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num, kwargs["total_height"],
joint_config=joint_config, lh_device_id=deck.name)
else:
self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num)
self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False)
super().__init__(backend, deck)
@@ -140,6 +147,9 @@ class LiquidHandlerMiddleware(LiquidHandler):
offsets: Optional[List[Coordinate]] = None,
**backend_kwargs,
):
# 如果 use_channels 为 None使用默认值所有通道
if use_channels is None:
use_channels = list(range(self.channel_num))
if not offsets or (isinstance(offsets, list) and len(offsets) != len(use_channels)):
offsets = [Coordinate.zero()] * len(use_channels)
if self._simulator:
@@ -217,7 +227,6 @@ class LiquidHandlerMiddleware(LiquidHandler):
offsets,
liquid_height,
blow_out_air_volume,
spread,
**backend_kwargs,
)
@@ -753,7 +762,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
blow_out_air_volume=current_dis_blow_out_air_volume,
spread=spread,
)
if delays is not None:
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(current_targets)
await self.discard_tips()
@@ -827,17 +836,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
spread=spread,
)
if delays is not None:
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.mix(
targets=[targets[_]],
mix_time=mix_time,
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,
)
if delays is not None:
# 只有在 mix_time 有效时才调用 mix
if mix_time is not None and mix_time > 0:
await self.mix(
targets=[targets[_]],
mix_time=mix_time,
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,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(targets[_])
await self.discard_tips()
@@ -887,18 +898,20 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
blow_out_air_volume=current_dis_blow_out_air_volume,
spread=spread,
)
if delays is not None:
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.mix(
targets=current_targets,
mix_time=mix_time,
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,
)
if delays is not None:
# 只有在 mix_time 有效时才调用 mix
if mix_time is not None and mix_time > 0:
await self.mix(
targets=current_targets,
mix_time=mix_time,
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,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(current_targets)
await self.discard_tips()
@@ -936,60 +949,158 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
delays: Optional[List[int]] = None,
none_keys: List[str] = [],
):
"""Transfer liquid from each *source* well/plate to the corresponding *target*.
"""Transfer liquid with automatic mode detection.
Supports three transfer modes:
1. One-to-many (1 source -> N targets): Distribute from one source to multiple targets
2. One-to-one (N sources -> N targets): Standard transfer, each source to corresponding target
3. Many-to-one (N sources -> 1 target): Combine multiple sources into one target
Parameters
----------
asp_vols, dis_vols
Single volume (µL) or list matching the number of transfers.
Single volume (µL) or list. Automatically expanded based on transfer mode.
sources, targets
Samelength sequences of containers (wells or plates). In 96well mode
each must contain exactly one plate.
Containers (wells or plates). Length determines transfer mode:
- len(sources) == 1, len(targets) > 1: One-to-many mode
- len(sources) == len(targets): One-to-one mode
- len(sources) > 1, len(targets) == 1: Many-to-one mode
tip_racks
One or more TipRacks providing fresh tips.
is_96_well
Set *True* to use the 96channel head.
"""
# 确保 use_channels 有默认值
if use_channels is None:
use_channels = [0] if self.channel_num >= 1 else list(range(self.channel_num))
if is_96_well:
pass # This mode is not verified.
else:
if len(asp_vols) != len(targets):
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
# 转换体积参数为列表
if isinstance(asp_vols, (int, float)):
asp_vols = [float(asp_vols)]
else:
asp_vols = [float(v) for v in asp_vols]
if isinstance(dis_vols, (int, float)):
dis_vols = [float(dis_vols)]
else:
dis_vols = [float(v) for v in dis_vols]
# 首先应该对任务分组然后每次1个/8个进行操作处理
if len(use_channels) == 1:
for _ in range(len(targets)):
tip = []
for ___ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
# 统一混合次数为标量,防止数组/列表与 int 比较时报错
if mix_times is not None and not isinstance(mix_times, (int, float)):
try:
mix_times = mix_times[0] if len(mix_times) > 0 else None
except Exception:
try:
mix_times = next(iter(mix_times))
except Exception:
pass
if mix_times is not None:
mix_times = int(mix_times)
# 识别传输模式
num_sources = len(sources)
num_targets = len(targets)
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."
)
await self.aspirate(
resources=[sources[_]],
vols=[asp_vols[_]],
use_channels=use_channels,
flow_rates=[asp_flow_rates[0]] if asp_flow_rates else None,
offsets=[offsets[0]] if offsets else None,
liquid_height=[liquid_height[0]] if liquid_height else None,
blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=[targets[_]],
vols=[dis_vols[_]],
use_channels=use_channels,
flow_rates=[dis_flow_rates[1]] if dis_flow_rates else None,
offsets=[offsets[1]] if offsets else None,
blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None,
liquid_height=[liquid_height[1]] if liquid_height else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[1])
async def _transfer_one_to_one(
self,
sources: Sequence[Container],
targets: Sequence[Container],
tip_racks: Sequence[TipRack],
use_channels: List[int],
asp_vols: List[float],
dis_vols: List[float],
asp_flow_rates: Optional[List[Optional[float]]],
dis_flow_rates: Optional[List[Optional[float]]],
offsets: Optional[List[Coordinate]],
touch_tip: bool,
liquid_height: Optional[List[Optional[float]]],
blow_out_air_volume: Optional[List[Optional[float]]],
spread: Literal["wide", "tight", "custom"],
mix_stage: Optional[Literal["none", "before", "after", "both"]],
mix_times: Optional[int],
mix_vol: Optional[int],
mix_rate: Optional[int],
mix_liquid_height: Optional[float],
delays: Optional[List[int]],
):
"""一对一传输模式N sources -> N targets"""
# 验证参数长度
if len(asp_vols) != len(targets):
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
if len(dis_vols) != 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:
for _ in range(len(targets)):
tip = []
for ___ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.aspirate(
resources=[sources[_]],
vols=[asp_vols[_]],
use_channels=use_channels,
flow_rates=[asp_flow_rates[_]] if asp_flow_rates and len(asp_flow_rates) > _ else None,
offsets=[offsets[_]] if offsets and len(offsets) > _ else None,
liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None,
blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=[targets[_]],
vols=[dis_vols[_]],
use_channels=use_channels,
flow_rates=[dis_flow_rates[_]] if dis_flow_rates and len(dis_flow_rates) > _ else None,
offsets=[offsets[_]] if offsets and len(offsets) > _ else None,
blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None,
liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ 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[_]],
mix_time=mix_times,
@@ -998,63 +1109,60 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if delays is not None:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(targets[_])
await self.discard_tips()
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(targets[_])
await self.discard_tips(use_channels=use_channels)
elif len(use_channels) == 8:
# 对于8个的情况需要判断此时任务是不是能被8通道移液站来成功处理
if len(targets) % 8 != 0:
raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.")
elif len(use_channels) == 8:
if len(targets) % 8 != 0:
raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.")
# 8个8个来取任务序列
for i in range(0, len(targets), 8):
tip = []
for _ in range(len(use_channels)):
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]
current_asp_vols = asp_vols[i:i + 8]
current_dis_vols = dis_vols[i:i + 8]
current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
for i in range(0, len(targets), 8):
# 取出8个任务
tip = []
for _ in range(len(use_channels)):
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]
current_asp_vols = asp_vols[i:i + 8]
current_dis_vols = dis_vols[i:i + 8]
current_asp_flow_rates = asp_flow_rates[i:i + 8]
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
current_dis_offset = offsets[-i*8-8:len(offsets)-i*8] if offsets else [None] * 8
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_dis_liquid_height = liquid_height[-i*8-8:len(liquid_height)-i*8] if liquid_height else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[-i*8-8:len(blow_out_air_volume)-i*8] if blow_out_air_volume else [None] * 8
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else [None] * 8
await self.aspirate(
resources=current_reagent_sources,
vols=current_asp_vols,
use_channels=use_channels,
flow_rates=current_asp_flow_rates,
offsets=current_asp_offset,
blow_out_air_volume=current_asp_blow_out_air_volume,
liquid_height=current_asp_liquid_height,
spread=spread,
)
await self.aspirate(
resources=current_reagent_sources,
vols=current_asp_vols,
use_channels=use_channels,
flow_rates=current_asp_flow_rates,
offsets=current_asp_offset,
blow_out_air_volume=current_asp_blow_out_air_volume,
liquid_height=current_asp_liquid_height,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=current_targets,
vols=current_dis_vols,
use_channels=use_channels,
flow_rates=current_dis_flow_rates,
offsets=current_dis_offset,
blow_out_air_volume=current_dis_blow_out_air_volume,
liquid_height=current_dis_liquid_height,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[1])
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=current_targets,
vols=current_dis_vols,
use_channels=use_channels,
flow_rates=current_dis_flow_rates,
offsets=current_dis_offset,
blow_out_air_volume=current_dis_blow_out_air_volume,
liquid_height=current_dis_liquid_height,
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=current_targets,
mix_time=mix_times,
@@ -1063,10 +1171,363 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if delays is not None:
await self.custom_delay(seconds=delays[1])
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(current_targets)
await self.discard_tips([0,1,2,3,4,5,6,7])
async def _transfer_one_to_many(
self,
source: Container,
targets: Sequence[Container],
tip_racks: Sequence[TipRack],
use_channels: List[int],
asp_vols: List[float],
dis_vols: List[float],
asp_flow_rates: Optional[List[Optional[float]]],
dis_flow_rates: Optional[List[Optional[float]]],
offsets: Optional[List[Coordinate]],
touch_tip: bool,
liquid_height: Optional[List[Optional[float]]],
blow_out_air_volume: Optional[List[Optional[float]]],
spread: Literal["wide", "tight", "custom"],
mix_stage: Optional[Literal["none", "before", "after", "both"]],
mix_times: Optional[int],
mix_vol: Optional[int],
mix_rate: Optional[int],
mix_liquid_height: Optional[float],
delays: Optional[List[int]],
):
"""一对多传输模式1 source -> N targets"""
# 验证和扩展体积参数
if len(asp_vols) == 1:
# 如果只提供一个吸液体积,计算总吸液体积(所有分液体积之和)
total_asp_vol = sum(dis_vols)
asp_vol = asp_vols[0] if asp_vols[0] >= total_asp_vol else total_asp_vol
else:
raise ValueError("For one-to-many mode, `asp_vols` should be a single value or list with one element.")
if len(dis_vols) != len(targets):
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
if len(use_channels) == 1:
# 单通道模式:一次吸液,多次分液
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
# 从源容器吸液(总体积)
await self.aspirate(
resources=[source],
vols=[asp_vol],
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])
# 分多次分液到不同的目标容器
for idx, target in enumerate(targets):
await self.dispense(
resources=[target],
vols=[dis_vols[idx]],
use_channels=use_channels,
flow_rates=[dis_flow_rates[idx]] if dis_flow_rates and len(dis_flow_rates) > idx else None,
offsets=[offsets[idx]] if offsets and len(offsets) > idx else None,
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx 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=[target],
mix_time=mix_times,
mix_vol=mix_vol,
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,
)
if touch_tip:
await self.touch_tip([target])
await self.discard_tips(use_channels=use_channels)
elif len(use_channels) == 8:
# 8通道模式需要确保目标数量是8的倍数
if len(targets) % 8 != 0:
raise ValueError(f"For 8-channel mode, number of targets {len(targets)} must be a multiple of 8.")
# 每次处理8个目标
for i in range(0, len(targets), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_targets = targets[i:i + 8]
current_dis_vols = dis_vols[i:i + 8]
# 8个通道都从同一个源容器吸液每个通道的吸液体积等于对应的分液体积
current_asp_flow_rates = asp_flow_rates[0:1] * 8 if asp_flow_rates and len(asp_flow_rates) > 0 else None
current_asp_offset = offsets[0:1] * 8 if offsets and len(offsets) > 0 else [None] * 8
current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8
# 从源容器吸液8个通道都从同一个源但每个通道的吸液体积不同
await self.aspirate(
resources=[source] * 8, # 8个通道都从同一个源
vols=current_dis_vols, # 每个通道的吸液体积等于对应的分液体积
use_channels=use_channels,
flow_rates=current_asp_flow_rates,
offsets=current_asp_offset,
liquid_height=current_asp_liquid_height,
blow_out_air_volume=current_asp_blow_out_air_volume,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
# 分液到8个目标
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
await self.dispense(
resources=current_targets,
vols=current_dis_vols,
use_channels=use_channels,
flow_rates=current_dis_flow_rates,
offsets=current_dis_offset,
blow_out_air_volume=current_dis_blow_out_air_volume,
liquid_height=current_dis_liquid_height,
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=current_targets,
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,
)
if touch_tip:
await self.touch_tip(current_targets)
await self.discard_tips([0,1,2,3,4,5,6,7])
await self.discard_tips([0,1,2,3,4,5,6,7])
async def _transfer_many_to_one(
self,
sources: Sequence[Container],
target: Container,
tip_racks: Sequence[TipRack],
use_channels: List[int],
asp_vols: List[float],
dis_vols: List[float],
asp_flow_rates: Optional[List[Optional[float]]],
dis_flow_rates: Optional[List[Optional[float]]],
offsets: Optional[List[Coordinate]],
touch_tip: bool,
liquid_height: Optional[List[Optional[float]]],
blow_out_air_volume: Optional[List[Optional[float]]],
spread: Literal["wide", "tight", "custom"],
mix_stage: Optional[Literal["none", "before", "after", "both"]],
mix_times: Optional[int],
mix_vol: Optional[int],
mix_rate: Optional[int],
mix_liquid_height: Optional[float],
delays: Optional[List[int]],
):
"""多对一传输模式N sources -> 1 target汇总/混合)"""
# 验证和扩展体积参数
if len(asp_vols) != 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每个源按不同比例分液按比例混合
if len(dis_vols) == 1:
# 模式1使用单个分液体积
total_dis_vol = sum(asp_vols)
dis_vol = dis_vols[0] if dis_vols[0] >= total_dis_vol else total_dis_vol
use_proportional_mixing = False
elif len(dis_vols) == len(asp_vols):
# 模式2按不同比例混合
use_proportional_mixing = True
else:
raise ValueError(
f"For many-to-one mode, `dis_vols` should be a single value or list with length {len(asp_vols)} "
f"(matching `asp_vols`). Got length {len(dis_vols)}."
)
if len(use_channels) == 1:
# 单通道模式:多次吸液,一次分液
# 先混合前(如果需要)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
# 注意:在吸液前混合源容器通常不常见,这里跳过
pass
# 从每个源容器吸液并分液到目标容器
for idx, source in enumerate(sources):
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.aspirate(
resources=[source],
vols=[asp_vols[idx]],
use_channels=use_channels,
flow_rates=[asp_flow_rates[idx]] if asp_flow_rates and len(asp_flow_rates) > idx else None,
offsets=[offsets[idx]] if offsets and len(offsets) > idx else None,
liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None,
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
# 分液到目标容器
if use_proportional_mixing:
# 按不同比例混合:使用对应的 dis_vols
dis_vol = dis_vols[idx]
dis_flow_rate = dis_flow_rates[idx] if dis_flow_rates and len(dis_flow_rates) > idx else None
dis_offset = offsets[idx] if offsets and len(offsets) > idx else None
dis_liquid_height = liquid_height[idx] if liquid_height and len(liquid_height) > idx else None
dis_blow_out = blow_out_air_volume[idx] if blow_out_air_volume and len(blow_out_air_volume) > idx else None
else:
# 标准模式:分液体积等于吸液体积
dis_vol = asp_vols[idx]
dis_flow_rate = dis_flow_rates[0] if dis_flow_rates and len(dis_flow_rates) > 0 else None
dis_offset = offsets[0] if offsets and len(offsets) > 0 else None
dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None
dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
await self.dispense(
resources=[target],
vols=[dis_vol],
use_channels=use_channels,
flow_rates=[dis_flow_rate] if dis_flow_rate is not None else None,
offsets=[dis_offset] if dis_offset is not None else None,
blow_out_air_volume=[dis_blow_out] if dis_blow_out is not None else None,
liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.discard_tips(use_channels=use_channels)
# 最后在目标容器中混合(如果需要)
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,
mix_vol=mix_vol,
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,
)
if touch_tip:
await self.touch_tip([target])
elif len(use_channels) == 8:
# 8通道模式需要确保源数量是8的倍数
if len(sources) % 8 != 0:
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
# 每次处理8个源
for i in range(0, len(sources), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_sources = sources[i:i + 8]
current_asp_vols = asp_vols[i:i + 8]
current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
# 从8个源容器吸液
await self.aspirate(
resources=current_sources,
vols=current_asp_vols,
use_channels=use_channels,
flow_rates=current_asp_flow_rates,
offsets=current_asp_offset,
blow_out_air_volume=current_asp_blow_out_air_volume,
liquid_height=current_asp_liquid_height,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
# 分液到目标容器(每个通道分液到同一个目标)
if use_proportional_mixing:
# 按比例混合:使用对应的 dis_vols
current_dis_vols = dis_vols[i:i + 8]
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
else:
# 标准模式:每个通道分液体积等于其吸液体积
current_dis_vols = current_asp_vols
current_dis_flow_rates = dis_flow_rates[0:1] * 8 if dis_flow_rates else None
current_dis_offset = offsets[0:1] * 8 if offsets else [None] * 8
current_dis_liquid_height = liquid_height[0:1] * 8 if liquid_height else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume else [None] * 8
await self.dispense(
resources=[target] * 8, # 8个通道都分到同一个目标
vols=current_dis_vols,
use_channels=use_channels,
flow_rates=current_dis_flow_rates,
offsets=current_dis_offset,
blow_out_air_volume=current_dis_blow_out_air_volume,
liquid_height=current_dis_liquid_height,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.discard_tips([0,1,2,3,4,5,6,7])
# 最后在目标容器中混合(如果需要)
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,
mix_vol=mix_vol,
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,
)
if touch_tip:
await self.touch_tip([target])
# except Exception as e:
# traceback.print_exc()

View File

@@ -41151,5 +41151,6 @@
"uuid": "730067cf07ae43849ddf4034299030e9"
}
}
}
]
],
"links": []
}

View File

@@ -5,6 +5,7 @@ import json
import os
import socket
import time
import uuid
from typing import Any, List, Dict, Optional, Tuple, TypedDict, Union, Sequence, Iterator, Literal
from pylabrobot.liquid_handling import (
@@ -65,7 +66,7 @@ class PRCXI9300Deck(Deck):
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float):
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
super().__init__(name, size_x, size_y, size_z)
self.slots = [None] * 6 # PRCXI 9300 有 6 个槽位
@@ -85,6 +86,7 @@ class PRCXI9300Container(Plate, TipRack):
category: str,
ordering: collections.OrderedDict,
model: Optional[str] = None,
**kwargs,
):
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
self._unilabos_state = {}
@@ -145,6 +147,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
setup=True,
debug=False,
simulator=False,
step_mode=False,
matrix_id="",
is_9320=False,
):
@@ -158,6 +161,13 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
)
if is_9320:
print("当前设备是9320")
# 始终初始化 step_mode 属性
self.step_mode = False
if step_mode:
if is_9320:
self.step_mode = step_mode
else:
print("9300设备不支持 单点动作模式")
self._unilabos_backend = PRCXI9300Backend(
tablets_info, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320
)
@@ -344,6 +354,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
offsets: Optional[List[Coordinate]] = None,
**backend_kwargs,
):
if self.step_mode:
await self.create_protocol(f"单点动作{time.time()}")
await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
await self.run_protocol()
return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
async def aspirate(
@@ -506,10 +520,26 @@ class PRCXI9300Backend(LiquidHandlerBackend):
await super().setup()
try:
if self._execute_setup:
# 先获取错误代码
error_code = self.api_client.get_error_code()
if error_code:
print(f"PRCXI9300 error code detected: {error_code}")
# 清除错误代码
self.api_client.clear_error_code()
print("PRCXI9300 error code cleared.")
# 执行重置
print("Starting PRCXI9300 reset...")
self.api_client.call("IAutomation", "Reset")
# 检查重置状态并等待完成
while not self.is_reset_ok:
print("Waiting for PRCXI9300 to reset...")
await self._ros_node.sleep(1)
if hasattr(self, '_ros_node') and self._ros_node is not None:
await self._ros_node.sleep(1)
else:
await asyncio.sleep(1)
print("PRCXI9300 reset successfully.")
except ConnectionRefusedError as e:
raise RuntimeError(
@@ -827,7 +857,30 @@ class PRCXI9300Api:
def _raw_request(self, payload: str) -> str:
if self.debug:
return " "
# 调试/仿真模式下直接返回可解析的模拟 JSON避免后续 json.loads 报错
try:
req = json.loads(payload)
method = req.get("MethodName")
except Exception:
method = None
data: Any = True
if method in {"AddSolution"}:
data = str(uuid.uuid4())
elif method in {"AddWorkTabletMatrix", "AddWorkTabletMatrix2"}:
data = {"Success": True, "Message": "debug mock"}
elif method in {"GetErrorCode"}:
data = ""
elif method in {"RemoveErrorCodet", "Reset", "Start", "LoadSolution", "Pause", "Resume", "Stop"}:
data = True
elif method in {"GetStepStateList", "GetStepStatus", "GetStepState"}:
data = []
elif method in {"GetLocation"}:
data = {"X": 0, "Y": 0, "Z": 0}
elif method in {"GetResetStatus"}:
data = False
return json.dumps({"Success": True, "Msg": "debug mock", "Data": data})
with contextlib.closing(socket.socket()) as sock:
sock.settimeout(self.timeout)
sock.connect((self.host, self.port))
@@ -1138,7 +1191,7 @@ class DefaultLayout:
self.waste_liquid_slot = 6
elif product_name == "PRCXI9320":
self.rows = 3
self.rows = 4
self.columns = 4
self.layout = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
self.trash_slot = 16
@@ -1694,11 +1747,47 @@ if __name__ == "__main__":
A = tree_to_list([resource_plr_to_ulab(deck)])
with open("deck.json", "w", encoding="utf-8") as f:
json.dump(A, f, indent=4, ensure_ascii=False)
A.insert(0, {
"id": "PRCXI",
"name": "PRCXI",
"parent": None,
"type": "device",
"class": "liquid_handler.prcxi",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"deck": {
"_resource_child_name": "PRCXI_Deck",
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck"
},
"host": "192.168.0.121",
"port": 9999,
"timeout": 10.0,
"axis": "Right",
"channel_num": 1,
"setup": False,
"debug": True,
"simulator": True,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"is_9320": True
},
"data": {},
"children": [
"PRCXI_Deck"
]
})
A[1]["parent"] = "PRCXI"
json.dump({
"nodes": A,
"links": []
}, f, indent=4, ensure_ascii=False)
handler = PRCXI9300Handler(
deck=deck,
host="192.168.0.121",
host="192.168.1.201",
port=9999,
timeout=10.0,
setup=True,
@@ -1735,6 +1824,12 @@ if __name__ == "__main__":
asyncio.run(handler.run_protocol())
time.sleep(5)
os._exit(0)
prcxi_api = PRCXI9300Api(host="192.168.0.121", port=9999)
prcxi_api.list_matrices()
prcxi_api.get_all_materials()
# 第一种情景:一个孔往多个孔加液
# plate_2_liquids = handler.set_group("water", [plate2.children[0]], [300])
# plate5_liquids = handler.set_group("master_mix", plate5.children[:23], [100]*23)

View File

@@ -0,0 +1,21 @@
import collections
import json
from pathlib import Path
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Container
prcxi_materials_path = str(Path(__file__).parent / "prcxi_material.json")
with open(prcxi_materials_path, mode="r", encoding="utf-8") as f:
prcxi_materials = json.loads(f.read())
def tip_adaptor_1250ul(name="Tip头适配器 1250uL") -> PRCXI9300Container: # 必须传入一个name参数是plr的规范要求
# tip_rack = PRCXI9300Container(name, prcxi_materials["name"]["Height"])
tip_rack = PRCXI9300Container(name, 1000,400,800, "tip_rack", collections.OrderedDict())
tip_rack.load_state({
"Materials": {"uuid": "7960f49ddfe9448abadda89bd1556936", "materialEnum": "0"}
})
return tip_rack

View File

@@ -1,5 +1,6 @@
import json
import threading
from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import (
@@ -30,7 +31,7 @@ from rclpy.action import ActionClient
from unilabos_msgs.action import SendCmd
import re
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
from unilabos.devices.ros_dev.liquid_handler_joint_publisher_node import LiquidHandlerJointPublisher
class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
@@ -48,27 +49,44 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
_max_volume_length = 16
_fitting_depth_length = 20
_tip_length_length = 16
# _pickup_method_length = 20
_filter_length = 10
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310):
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, **kwargs):
"""Initialize a chatter box backend."""
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
# rclpy.init()
self.joint_config = kwargs.get("joint_config", None)
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
self.executor = None
self.executor_thread = None
async def setup(self):
self.joint_state_publisher = JointStatePublisher()
self.joint_state_publisher = LiquidHandlerJointPublisher(
joint_config=self.joint_config,
lh_device_id=self.lh_device_id,
simulate_rviz=True)
# 启动ROS executor
self.executor = rclpy.executors.MultiThreadedExecutor()
self.executor.add_node(self.joint_state_publisher)
self.executor_thread = threading.Thread(target=self.executor.spin, daemon=True)
self.executor_thread.start()
await super().setup()
print("Setting up the liquid handler.")
async def stop(self):
# 停止ROS executor
if self.executor and self.joint_state_publisher:
self.executor.remove_node(self.joint_state_publisher)
if self.executor_thread and self.executor_thread.is_alive():
self.executor.shutdown()
print("Stopping the liquid handler.")
def serialize(self) -> dict:
@@ -123,7 +141,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print("moving")
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
# goback()
@@ -166,7 +184,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash",channels=use_channels)
self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "drop_trash",channels=use_channels)
# goback()
async def aspirate(
@@ -216,7 +234,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "",channels=use_channels)
async def dispense(
@@ -264,9 +282,8 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "",channels=use_channels)
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
print(f"Picking up tips from {pickup.resource.name}.")

View File

@@ -0,0 +1,20 @@
{
"nodes": [
{
"id": "opsky_ATR30007",
"name": "opsky_ATR30007",
"parent": null,
"type": "device",
"class": "opsky_ATR30007",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": []
}
],
"links": []
}

View File

@@ -0,0 +1,71 @@
import socket
import time
import csv
from datetime import datetime
import threading
csv_lock = threading.Lock() # 防止多线程写CSV冲突
def scan_once(ip="192.168.1.50", port_in=2001, port_out=2002,
csv_file="scan_results.csv", timeout=5, retries=3):
"""
改进版扫码函数:
- 自动重试
- 全程超时保护
- 更安全的socket关闭
- 文件写入加锁
"""
def save_result(qrcode):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with csv_lock:
with open(csv_file, mode="a", newline="") as f:
writer = csv.writer(f)
writer.writerow([timestamp, qrcode])
print(f"✅ 已保存结果: {timestamp}, {qrcode}")
result = None
for attempt in range(1, retries + 1):
print(f"\n🟡 扫码尝试 {attempt}/{retries} ...")
try:
# -------- Step 1: 触发拍照 --------
with socket.create_connection((ip, port_in), timeout=2) as client_in:
cmd = "start"
client_in.sendall(cmd.encode("ascii")) #把字符串转为byte字节流规则是ascii码
print(f"→ 已发送触发指令: {cmd}")
# -------- Step 2: 等待识别结果 --------
with socket.create_connection((ip, port_out), timeout=timeout) as client_out:
print(f" 已连接相机输出端口 {port_out},等待结果...")
# recv最多阻塞timeout秒
client_out.settimeout(timeout)
data = client_out.recv(2048).decode("ascii", errors="ignore").strip() #结果输出为ascii字符串遇到无法解析的字节则忽略
# .strip():去掉字符串头尾的空白字符(包括 \n, \r, 空格等),便于后续判断是否为空或写入 CSV。
if data:
print(f"📷 识别结果: {data}")
save_result(data) #调用 save_result(data) 把时间戳 + 识别字符串写入 CSV线程安全
result = data #把局部变量 result 设为 data用于函数返回值
break #如果读取成功跳出重试循环for attempt in ...),不再进行后续重试。
else:
print("⚠️ 相机返回空数据,重试中...")
except socket.timeout:
print("⏰ 超时未收到识别结果,重试中...")
except ConnectionRefusedError:
print("❌ 无法连接到扫码器端口,请检查设备是否在线。")
except OSError as e:
print(f"⚠️ 网络错误: {e}")
except Exception as e:
print(f"❌ 未知异常: {e}")
time.sleep(0.5) # 两次扫描之间稍作延时
# -------- Step 3: 返回最终结果 --------
if result:
print(f"✅ 扫码成功:{result}")
else:
print("❌ 多次尝试后仍未获取二维码结果")
return result

View File

@@ -0,0 +1,398 @@
# opsky_atr30007.py
import logging
import time as time_mod
import csv
from datetime import datetime
from typing import Optional, Dict, Any
# 兼容 pymodbus 在不同版本中的位置与 API
try:
from pymodbus.client import ModbusTcpClient
except Exception:
ModbusTcpClient = None
# 导入 run_raman_test假定与本文件同目录
# 如果你的项目是包结构且原先使用相对导入,请改回 `from .raman_module import run_raman_test`
try:
from .raman_module import run_raman_test
except Exception:
# 延迟导入失败不会阻止主流程(在 run 时会再尝试)
run_raman_test = None
logger = logging.getLogger("opsky")
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%y-%m-%d %H:%M:%S")
ch.setFormatter(formatter)
logger.addHandler(ch)
class opsky_ATR30007:
"""
封装 UniLabOS 设备动作逻辑,兼容 pymodbus 2.x / 3.x。
放在独立文件中opsky_atr30007.py
"""
def __init__(
self,
plc_ip: str = "192.168.1.88",
plc_port: int = 502,
robot_ip: str = "192.168.1.200",
robot_port: int = 502,
scan_csv_file: str = "scan_results.csv",
):
self.plc_ip = plc_ip
self.plc_port = plc_port
self.robot_ip = robot_ip
self.robot_port = robot_port
self.scan_csv_file = scan_csv_file
# ----------------- 参数字符串转换 helpers -----------------
@staticmethod
def _str_to_int(s, default):
try:
return int(float(str(s).strip()))
except Exception:
return int(default)
@staticmethod
def _str_to_float(s, default):
try:
return float(str(s).strip())
except Exception:
return float(default)
@staticmethod
def _str_to_bool(s, default):
try:
v = str(s).strip().lower()
if v in ("true", "1", "yes", "y", "t"):
return True
if v in ("false", "0", "no", "n", "f"):
return False
return default
except Exception:
return default
# ----------------- Modbus / 安全读写 -----------------
@staticmethod
def _adapt_req_kwargs_for_read(func_name: str, args: tuple, kwargs: dict):
# 如果调用方传的是 (address, count) positional在新版接口可能是 address=..., count=...
if len(args) == 2 and func_name.startswith("read_"):
address, count = args
args = ()
kwargs.setdefault("address", address)
kwargs.setdefault("count", count)
return args, kwargs
@staticmethod
def _adapt_req_kwargs_for_write(func_name: str, args: tuple, kwargs: dict):
if len(args) == 2 and func_name.startswith("write_"):
address, value = args
args = ()
kwargs.setdefault("address", address)
kwargs.setdefault("value", value)
return args, kwargs
def ensure_connected(self, client, name, ip, port):
"""确保连接存在,失败则尝试重连并返回新的 client 或 None"""
if client is None:
return None
try:
# 不同 pymodbus 版本可能有不同方法检测 socket
is_open = False
try:
is_open = bool(client.is_socket_open())
except Exception:
# fallback: try to read nothing or attempt connection test
try:
# 轻试一次
is_open = client.connected if hasattr(client, "connected") else False
except Exception:
is_open = False
if not is_open:
logger.warning("%s 掉线,尝试重连...", name)
try:
client.close()
except Exception:
pass
time_mod.sleep(0.5)
if ModbusTcpClient:
new_client = ModbusTcpClient(ip, port=port)
try:
if new_client.connect():
logger.info("%s 重新连接成功 (%s:%s)", name, ip, port)
return new_client
except Exception:
pass
logger.warning("%s 重连失败", name)
time_mod.sleep(1)
return None
return client
except Exception as e:
logger.exception("%s 连接检查异常: %s", name, e)
return None
def safe_read(self, client, name, func, *args, retries=3, delay=0.3, **kwargs):
"""兼容 pymodbus 2.x/3.x 的读函数,返回 response 或 None"""
if client is None:
return None
for attempt in range(1, retries + 1):
try:
# adapt args/kwargs for different API styles
args, kwargs = self._adapt_req_kwargs_for_read(func.__name__, args, kwargs)
# unit->slave compatibility
if "unit" in kwargs:
kwargs["slave"] = kwargs.pop("unit")
res = func(*args, **kwargs)
# pymodbus Response 在不同版本表现不同,尽量检测错误
if res is None:
raise RuntimeError("返回 None")
if hasattr(res, "isError") and res.isError():
raise RuntimeError("Modbus 返回 isError()")
return res
except Exception as e:
logger.warning("%s 读异常 (尝试 %d/%d): %s", name, attempt, retries, e)
time_mod.sleep(delay)
logger.error("%s 连续读取失败 %d", name, retries)
return None
def safe_write(self, client, name, func, *args, retries=3, delay=0.3, **kwargs):
"""兼容 pymodbus 2.x/3.x 的写函数,返回 True/False"""
if client is None:
return False
for attempt in range(1, retries + 1):
try:
args, kwargs = self._adapt_req_kwargs_for_write(func.__name__, args, kwargs)
if "unit" in kwargs:
kwargs["slave"] = kwargs.pop("unit")
res = func(*args, **kwargs)
if res is None:
raise RuntimeError("返回 None")
if hasattr(res, "isError") and res.isError():
raise RuntimeError("Modbus 返回 isError()")
return True
except Exception as e:
logger.warning("%s 写异常 (尝试 %d/%d): %s", name, attempt, retries, e)
time_mod.sleep(delay)
logger.error("%s 连续写入失败 %d", name, retries)
return False
def wait_with_quit_check(self, robot, seconds, addr_quit=270):
"""等待指定时间,同时每 0.2s 检查 R270 是否为 1立即退出"""
if robot is None:
time_mod.sleep(seconds)
return False
checks = max(1, int(seconds / 0.2))
for _ in range(checks):
rr = self.safe_read(robot, "机器人", robot.read_holding_registers, address=addr_quit, count=1)
if rr and getattr(rr, "registers", [None])[0] == 1:
logger.info("检测到 R270=1立即退出等待")
return True
time_mod.sleep(0.2)
return False
# ----------------- 主流程 run_once -----------------
def run_once(
self,
integration_time: str = "5000",
laser_power: str = "200",
save_csv: str = "true",
save_plot: str = "true",
normalize: str = "true",
norm_max: str = "1.0",
**_: Any,
) -> Dict[str, Any]:
result: Dict[str, Any] = {"success": False, "event": "none", "details": {}}
integration_time_v = self._str_to_int(integration_time, 5000)
laser_power_v = self._str_to_int(laser_power, 200)
save_csv_v = self._str_to_bool(save_csv, True)
save_plot_v = self._str_to_bool(save_plot, True)
normalize_v = self._str_to_bool(normalize, True)
norm_max_v = None if norm_max in (None, "", "none", "null") else self._str_to_float(norm_max, 1.0)
if ModbusTcpClient is None:
result["details"]["error"] = "未安装 pymodbus无法执行连接"
logger.error(result["details"]["error"])
return result
# 建立连接
plc = ModbusTcpClient(self.plc_ip, port=self.plc_port)
robot = ModbusTcpClient(self.robot_ip, port=self.robot_port)
try:
if not plc.connect():
result["details"]["error"] = "无法连接 PLC"
logger.error(result["details"]["error"])
return result
if not robot.connect():
plc.close()
result["details"]["error"] = "无法连接 机器人"
logger.error(result["details"]["error"])
return result
logger.info("✅ PLC 与 机器人连接成功")
time_mod.sleep(0.2)
# 伺服使能 (coil 写示例)
if self.safe_write(plc, "PLC", plc.write_coil, 10, True):
logger.info("✅ 伺服使能成功 (M10=True)")
else:
logger.warning("⚠️ 伺服使能失败")
# 初始化 CSV 文件
try:
with open(self.scan_csv_file, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerow(["Bottle_No", "Scan_Result", "Time"])
except Exception as e:
logger.warning("⚠️ 初始化CSV失败: %s", e)
bottle_count = 0
logger.info("🟢 等待机器人触发信号... (R260=1扫码 / R256=1拉曼 / R270=1退出)")
# 主循环:仅响应事件(每次循环后短暂 sleep
while True:
plc = self.ensure_connected(plc, "PLC", self.plc_ip, self.plc_port) or plc
robot = self.ensure_connected(robot, "机器人", self.robot_ip, self.robot_port) or robot
# 检查退出寄存器
quit_signal = self.safe_read(robot, "机器人", robot.read_holding_registers, 270, 1)
if quit_signal and getattr(quit_signal, "registers", [None])[0] == 1:
logger.info("🟥 检测到 R270=1准备退出...")
result["event"] = "quit"
result["success"] = True
break
# 读取关键寄存器256..260
rr = self.safe_read(robot, "机器人", robot.read_holding_registers, 256, 5)
if not rr or not hasattr(rr, "registers"):
time_mod.sleep(0.3)
continue
r256, r257, r258, r259, r260 = (rr.registers + [0, 0, 0, 0, 0])[:5]
# ---------- 扫码逻辑 ----------
if r260 == 1:
bottle_count += 1
logger.info("📸 第 %d 瓶触发扫码 (R260=1)", bottle_count)
try:
# 调用外部扫码函数(用户实现)
from .dmqfengzhuang import scan_once as scan_once_local
scan_result = scan_once_local(ip="192.168.1.50", port_in=2001, port_out=2002)
if scan_result:
logger.info("✅ 扫码成功: %s", scan_result)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(self.scan_csv_file, "a", newline="", encoding="utf-8") as f:
csv.writer(f).writerow([bottle_count, scan_result, timestamp])
else:
logger.warning("⚠️ 扫码失败或无返回")
except Exception as e:
logger.exception("❌ 扫码异常: %s", e)
# 写 R260->0, R261->1
self.safe_write(robot, "机器人", robot.write_register, 260, 0)
time_mod.sleep(0.15)
self.safe_write(robot, "机器人", robot.write_register, 261, 1)
logger.info("➡️ 扫码完成 (R260→0, R261→1)")
result["event"] = "scan"
result["success"] = True
# ---------- 拉曼逻辑 ----------
if r256 == 1:
logger.info("⚙️ 检测到 R256=1放瓶完成")
# PLC 电机右转指令
self.safe_write(plc, "PLC", plc.write_register, 1199, 1)
self.safe_write(plc, "PLC", plc.write_register, 1200, 1)
logger.info("➡️ 电机右转中...")
if self.wait_with_quit_check(robot, 3):
result["event"] = "quit"
break
self.safe_write(plc, "PLC", plc.write_register, 1199, 0)
logger.info("✅ 电机右转完成")
# 调用拉曼测试(尽量捕获异常并记录)
logger.info("🧪 开始拉曼测试...")
try:
# 尝试使用模块导入好的 run_raman_test否则再动态导入
rr_func = run_raman_test
if rr_func is None:
from raman_module import run_raman_test as rr_func
success, file_prefix, df = rr_func(
integration_time=integration_time_v,
laser_power=laser_power_v,
save_csv=save_csv_v,
save_plot=save_plot_v,
normalize=normalize_v,
norm_max=norm_max_v,
)
if success:
logger.info("✅ 拉曼测试完成: %s", file_prefix)
result["event"] = "raman"
result["success"] = True
else:
logger.warning("⚠️ 拉曼测试失败")
except Exception as e:
logger.exception("❌ 拉曼模块异常: %s", e)
# 电机左转回位
self.safe_write(plc, "PLC", plc.write_register, address=1299, value=1)
self.safe_write(plc, "PLC", plc.write_register, address=1300, value=1)
logger.info("⬅️ 电机左转中...")
if self.wait_with_quit_check(robot, 3):
result["event"] = "quit"
break
self.safe_write(plc, "PLC", plc.write_register, address=1299, value=0)
logger.info("✅ 电机左转完成")
# 通知机器人拉曼完成 R257=1
self.safe_write(robot, "机器人", robot.write_register, address=257, value=1)
logger.info("✅ 已写入 R257=1拉曼完成")
# 延迟后清零 R256
logger.info("⏳ 延迟4秒后清零 R256")
if self.wait_with_quit_check(robot, 4):
result["event"] = "quit"
break
self.safe_write(robot, "机器人", robot.write_register, address=256, value=0)
logger.info("✅ 已清零 R256")
# 等待机器人清 R257
logger.info("等待 R257 清零中...")
while True:
rr2 = self.safe_read(robot, "机器人", robot.read_holding_registers, address=257, count=1)
if rr2 and getattr(rr2, "registers", [None])[0] == 0:
logger.info("✅ 检测到 R257=0准备下一循环")
break
if self.wait_with_quit_check(robot, 1):
result["event"] = "quit"
break
time_mod.sleep(0.2)
time_mod.sleep(0.25)
finally:
logger.info("🧹 开始清理...")
try:
self.safe_write(plc, "PLC", plc.write_coil, address=10, value=False)
except Exception:
pass
for addr in [256, 257, 260, 261, 270]:
try:
self.safe_write(robot, "机器人", robot.write_register, address=addr, value=0)
except Exception:
pass
try:
if plc:
plc.close()
except Exception:
pass
try:
if robot:
robot.close()
except Exception:
pass
logger.info("🔚 已关闭所有连接")
return result

View File

@@ -0,0 +1,180 @@
# raman_module.py
import os
import time as time_mod
import numpy as np
import pandas as pd
# clr / ATRWrapper 依赖:在真实环境中使用 Windows + .NET wrapper
# 本模块对缺少 clr 或 Wrapper 的情况提供“仿真”回退,方便离线/调试运行。
try:
import clr
has_clr = True
except Exception:
clr = None
has_clr = False
# 本函数返回 (success: bool, file_prefix: str|None, df: pandas.DataFrame|None)
def run_raman_test(integration_time=5000, laser_power=200,
save_csv=True, save_plot=True,
normalize=False, norm_max=None,
max_wavenum=1300):
"""
拉曼测试流程(更稳健的实现):
- 若能加载 ATRWrapper 则使用之
- 否则生成模拟光谱(方便调试)
返回 (success, file_prefix, df)
"""
timestamp = time_mod.strftime("%Y%m%d_%H%M%S")
file_prefix = f"raman_{timestamp}"
wrapper = None
used_real_device = False
try:
if has_clr:
try:
# 请根据你的 DLL 路径调整
dll_path = r"D:\Raman\Raman_RS\ATRWrapper\ATRWrapper.dll"
if os.path.exists(dll_path):
clr.AddReference(dll_path)
else:
# 试图直接 AddReference 名称(若已在 PATH
try:
clr.AddReference("ATRWrapper")
except Exception:
pass
from Optosky.Wrapper import ATRWrapper # May raise
wrapper = ATRWrapper()
used_real_device = True
except Exception as e:
# 无法加载真实 wrapper -> fallback
print("⚠️ 未能加载 ATRWrapper使用模拟数据。详细:", e)
wrapper = None
if wrapper is None:
# 生成模拟光谱(方便调试)
# 模拟波数轴 50..1300
WaveNum = np.linspace(50, max_wavenum, 1024)
# 合成几条高斯峰 + 噪声
def gauss(x, mu, sig, A):
return A * np.exp(-0.5 * ((x - mu) / sig) ** 2)
Spect_data = (gauss(WaveNum, 200, 8, 1000) +
gauss(WaveNum, 520, 12, 600) +
gauss(WaveNum, 810, 20, 400) +
50 * np.random.normal(scale=1.0, size=WaveNum.shape))
Spect_bLC = Spect_data - np.min(Spect_data) * 0.05 # 简单 baseline
Spect_smooth = np.convolve(Spect_bLC, np.ones(3) / 3, mode="same")
df = pd.DataFrame({
"WaveNum": WaveNum,
"Raw_Spect": Spect_data,
"BaseLineCorrected": Spect_bLC,
"Smooth_Spect": Spect_smooth
})
success = True
file_prefix = f"raman_sim_{timestamp}"
# 保存 CSV / 绘图 等同真实设备
else:
# 使用真实设备 API根据你提供的 wrapper 调用)
On_flag = wrapper.OpenDevice()
print("通讯连接状态:", On_flag)
if not On_flag:
wrapper.CloseDevice()
return False, None, None
wrapper.SetIntegrationTime(int(integration_time))
wrapper.SetLdPower(int(laser_power), 1)
# 可能的冷却设置(如果 wrapper 支持)
try:
wrapper.SetCool(-5)
except Exception:
pass
Spect = wrapper.AcquireSpectrum()
Spect_data = np.array(Spect.get_Data())
if not Spect.get_Success():
print("光谱采集失败")
try:
wrapper.CloseDevice()
except Exception:
pass
return False, None, None
WaveNum = np.array(wrapper.GetWaveNum())
Spect_bLC = np.array(wrapper.BaseLineCorrect(Spect_data))
Spect_smooth = np.array(wrapper.SmoothBoxcar(Spect_bLC, 3))
df = pd.DataFrame({
"WaveNum": WaveNum,
"Raw_Spect": Spect_data,
"BaseLineCorrected": Spect_bLC,
"Smooth_Spect": Spect_smooth
})
wrapper.CloseDevice()
success = True
# 如果需要限定波数范围
mask = df["WaveNum"] <= max_wavenum
df = df[mask].reset_index(drop=True)
# 可选归一化
if normalize:
arr = df["Smooth_Spect"].values
mn, mx = arr.min(), arr.max()
if mx == mn:
df["Smooth_Spect"] = 0.0
else:
scale = 1.0 if norm_max is None else float(norm_max)
df["Smooth_Spect"] = (arr - mn) / (mx - mn) * scale
# 同时处理其它列(可选)
arr_raw = df["Raw_Spect"].values
mn_r, mx_r = arr_raw.min(), arr_raw.max()
if mx_r == mn_r:
df["Raw_Spect"] = 0.0
else:
scale = 1.0 if norm_max is None else float(norm_max)
df["Raw_Spect"] = (arr_raw - mn_r) / (mx_r - mn_r) * scale
# 保存 CSV
if save_csv:
csv_filename = f"{file_prefix}.csv"
df.to_csv(csv_filename, index=False)
print("✅ CSV 文件已生成:", csv_filename)
# 绘图(使用 matplotlib注意不要启用 GUI 后台
if save_plot:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plt.figure(figsize=(8, 5))
plt.plot(df["WaveNum"], df["Raw_Spect"], linestyle='-', alpha=0.6, label="原始")
plt.plot(df["WaveNum"], df["BaseLineCorrected"], linestyle='--', alpha=0.8, label="基线校正")
plt.plot(df["WaveNum"], df["Smooth_Spect"], linewidth=1.2, label="平滑")
plt.xlabel("WaveNum (cm^-1)")
plt.ylabel("Intensity (a.u.)")
plt.title(f"Raman {file_prefix}")
plt.grid(True)
plt.legend()
plt.tight_layout()
plot_filename = f"{file_prefix}.png"
plt.savefig(plot_filename, dpi=300, bbox_inches="tight")
plt.close()
# 小短暂等待以确保文件系统刷新
time_mod.sleep(0.2)
print("✅ 图像已生成:", plot_filename)
except Exception as e:
print("⚠️ 绘图失败:", e)
return success, file_prefix, df
except Exception as e:
print("拉曼测试异常:", e)
try:
if wrapper is not None:
try:
wrapper.CloseDevice()
except Exception:
pass
except Exception:
pass
return False, None, None

View File

@@ -0,0 +1,209 @@
import time
import csv
from datetime import datetime
from pymodbus.client import ModbusTcpClient
from dmqfengzhuang import scan_once
from raman_module import run_raman_test
# =================== 配置 ===================
PLC_IP = "192.168.1.88"
PLC_PORT = 502
ROBOT_IP = "192.168.1.200"
ROBOT_PORT = 502
SCAN_CSV_FILE = "scan_results.csv"
# =================== 通用函数 ===================
def ensure_connected(client, name, ip, port):
if not client.is_socket_open():
print(f"{name} 掉线,正在重连...")
client.close()
time.sleep(1)
new_client = ModbusTcpClient(ip, port=port)
if new_client.connect():
print(f"{name} 重新连接成功 ({ip}:{port})")
return new_client
else:
print(f"{name} 重连失败,稍后重试...")
time.sleep(3)
return None
return client
def safe_read(client, name, func, *args, retries=3, delay=0.3, **kwargs):
for _ in range(retries):
try:
res = func(*args, **kwargs)
if res and not (hasattr(res, "isError") and res.isError()):
return res
except Exception as e:
print(f"{name} 读异常: {e}")
time.sleep(delay)
print(f"{name} 连续读取失败 {retries}")
return None
def safe_write(client, name, func, *args, retries=3, delay=0.3, **kwargs):
for _ in range(retries):
try:
res = func(*args, **kwargs)
if res and not (hasattr(res, "isError") and res.isError()):
return True
except Exception as e:
print(f"{name} 写异常: {e}")
time.sleep(delay)
print(f"{name} 连续写入失败 {retries}")
return False
def wait_with_quit_check(robot, seconds, addr_quit=270):
for _ in range(int(seconds / 0.2)):
rr = safe_read(robot, "机器人", robot.read_holding_registers,
address=addr_quit, count=1)
if rr and rr.registers[0] == 1:
print("检测到 R270=1立即退出循环")
return True
time.sleep(0.2)
return False
# =================== 初始化 ===================
plc = ModbusTcpClient(PLC_IP, port=PLC_PORT)
robot = ModbusTcpClient(ROBOT_IP, port=ROBOT_PORT)
if not plc.connect():
print("无法连接 PLC")
exit()
if not robot.connect():
print("无法连接 机器人")
plc.close()
exit()
print("✅ PLC 与 机器人连接成功")
time.sleep(0.5)
# 伺服使能
if safe_write(plc, "PLC", plc.write_coil, address=10, value=True):
print("✅ 伺服使能成功 (M10=True)")
else:
print("⚠️ 伺服使能失败")
# 初始化扫码 CSV
with open(SCAN_CSV_FILE, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerow(["Bottle_No", "Scan_Result", "Time"])
bottle_count = 0
print("🟢 等待机器人触发信号... (R260=1扫码 / R256=1拉曼 / R270=1退出)")
# =================== 主监听循环 ===================
while True:
plc = ensure_connected(plc, "PLC", PLC_IP, PLC_PORT) or plc
robot = ensure_connected(robot, "机器人", ROBOT_IP, ROBOT_PORT) or robot
# 退出命令检测
quit_signal = safe_read(robot, "机器人", robot.read_holding_registers,
address=270, count=1)
if quit_signal and quit_signal.registers[0] == 1:
print("🟥 检测到 R270=1准备退出程序...")
break
# 读取关键寄存器
rr = safe_read(robot, "机器人", robot.read_holding_registers,
address=256, count=5)
if not rr:
time.sleep(0.3)
continue
r256, _, r258, r259, r260 = rr.registers[:5]
# ----------- 扫码部分 (R260=1) -----------
if r260 == 1:
bottle_count += 1
print(f"📸 第 {bottle_count} 瓶触发扫码 (R260=1)")
try:
result = scan_once(ip="192.168.1.50", port_in=2001, port_out=2002)
if result:
print(f"✅ 扫码成功: {result}")
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(SCAN_CSV_FILE, "a", newline="", encoding="utf-8") as f:
csv.writer(f).writerow([bottle_count, result, timestamp])
else:
print("⚠️ 扫码失败或无返回")
except Exception as e:
print(f"❌ 扫码异常: {e}")
safe_write(robot, "机器人", robot.write_register, address=260, value=0)
time.sleep(0.2)
safe_write(robot, "机器人", robot.write_register, address=261, value=1)
print("➡️ 扫码完成 (R260→0, R261→1)")
# ----------- 拉曼 + 电机部分 (R256=1) -----------
if r256 == 1:
print("⚙️ 检测到 R256=1放瓶完成")
# 电机右转
safe_write(plc, "PLC", plc.write_register, address=1199, value=1)
safe_write(plc, "PLC", plc.write_register, address=1200, value=1)
print("➡️ 电机右转中...")
if wait_with_quit_check(robot, 3):
break
safe_write(plc, "PLC", plc.write_register, address=1199, value=0)
print("✅ 电机右转完成")
# 拉曼测试
print("🧪 开始拉曼测试...")
try:
success, file_prefix, df = run_raman_test(
integration_time=5000,
laser_power=200,
save_csv=True,
save_plot=True,
normalize=True,
norm_max=1.0
)
if success:
print(f"✅ 拉曼完成:{file_prefix}.csv / .png")
else:
print("⚠️ 拉曼失败")
except Exception as e:
print(f"❌ 拉曼测试异常: {e}")
# 电机左转
safe_write(plc, "PLC", plc.write_register, address=1299, value=1)
safe_write(plc, "PLC", plc.write_register, address=1300, value=1)
print("⬅️ 电机左转中...")
if wait_with_quit_check(robot, 3):
break
safe_write(plc, "PLC", plc.write_register, address=1299, value=0)
print("✅ 电机左转完成")
# 写入拉曼完成信号
safe_write(robot, "机器人", robot.write_register, address=257, value=1)
print("✅ 已写入 R257=1拉曼完成")
# 延迟清零 R256
print("⏳ 延迟4秒后清零 R256")
if wait_with_quit_check(robot, 4):
break
safe_write(robot, "机器人", robot.write_register, address=256, value=0)
print("✅ 已清零 R256")
# 等待机器人清零 R257
print("等待 R257 清零中...")
while True:
rr2 = safe_read(robot, "机器人", robot.read_holding_registers, address=257, count=1)
if rr2 and rr2.registers[0] == 0:
print("✅ 检测到 R257=0准备下一循环")
break
if wait_with_quit_check(robot, 1):
break
time.sleep(0.2)
time.sleep(0.2)
# =================== 程序退出清理 ===================
print("🧹 开始清理...")
safe_write(plc, "PLC", plc.write_coil, address=10, value=False)
for addr in [256, 257, 260, 261, 270]:
safe_write(robot, "机器人", robot.write_register, address=addr, value=0)
plc.close()
robot.close()
print("✅ 程序已退出,设备全部复位。")

View File

@@ -30,5 +30,21 @@ class PlateContainer:
self.rotation = rotation
self.status = 'idle'
def get_rotation(self):
return self.rotation
class TubeRackContainer:
def __init__(self, rotation: dict, **kwargs):
self.rotation = rotation
self.status = 'idle'
def get_rotation(self):
return self.rotation
class BottleRackContainer:
def __init__(self, rotation: dict, **kwargs):
self.rotation = rotation
self.status = 'idle'
def get_rotation(self):
return self.rotation

View File

@@ -34,5 +34,35 @@
"offset":0.0
}
}
},
"TransformXYZDeck":{
"joint_names":[
"x_joint",
"y_joint",
"z_joint"
],
"link_names":[
"x_link",
"y_link",
"z_link"
],
"x":{
"y_joint":{
"factor":-0.001,
"offset":0.145
}
},
"y":{
"x_joint":{
"factor":0.001,
"offset":-0.21415
}
},
"z":{
"z_joint":{
"factor":-0.001,
"offset":0.0
}
}
}
}

View File

@@ -2,6 +2,7 @@ import asyncio
import copy
from pathlib import Path
import threading
import uuid
import rclpy
import json
import time
@@ -18,7 +19,7 @@ from rclpy.node import Node
import re
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher"):
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
super().__init__(
driver_instance=self,
device_id=device_id,
@@ -27,6 +28,7 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
hardware_interface={},
print_publish=False,
resource_tracker=resource_tracker,
device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
)
# 初始化参数
@@ -55,8 +57,8 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
# 初始化设备ID与config信息
for resource in resources_config:
if resource['class'] == 'liquid_handler':
deck_id = resource['config']['data']['children'][0]['_resource_child_name']
deck_class = resource['config']['data']['children'][0]['_resource_type'].split(':')[-1]
deck_id = resource['config']['deck']['_resource_child_name']
deck_class = resource['config']['deck']['_resource_type'].split(':')[-1]
key = f'{deck_id}'
# key = f'{resource["id"]}_{deck_id}'
self.lh_devices[key] = {
@@ -208,7 +210,7 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
return joint_positions ,z_index
def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None):
def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]):
if isinstance(resource_names, list):
resource_name_ = resource_names[0]
else:
@@ -217,9 +219,9 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
parent_id = self.find_resource_parent(resource_name_)
print('!'*20)
print(parent_id)
print('!'*20)
# print('!'*20)
# print(parent_id)
# print('!'*20)
if x_joint is None:
xa,xb = next(iter(self.lh_devices[parent_id]['joint_config']['x'].items()))
x_joint_config = {xa:xb}
@@ -252,11 +254,11 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
if option == "pick":
link_name = self.lh_devices[parent_id]['joint_config']['link_names'][z_index]
link_name = f'{parent_id}_{link_name}'
self.resource_move(resource_name_, link_name, [0,1,2,3,4,5,6,7])
self.resource_move(resource_name_, link_name, channels)
elif option == "drop_trash":
self.resource_move(resource_name_, "__trash", [0,1,2,3,4,5,6,7])
self.resource_move(resource_name_, "__trash", channels)
elif option == "drop":
self.resource_move(resource_name_, "world", [0,1,2,3,4,5,6,7])
self.resource_move(resource_name_, "world", channels)
self.move_to(joint_positions_target_zero, speed, parent_id)
@@ -325,8 +327,20 @@ class JointStatePublisher(Node):
return None
def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None):
def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]):
goal_msg = SendCmd.Goal()
# Convert numpy arrays or other non-serializable objects to lists
def to_serializable(obj):
if hasattr(obj, 'tolist'): # numpy array
return obj.tolist()
elif isinstance(obj, list):
return [to_serializable(item) for item in obj]
elif isinstance(obj, dict):
return {k: to_serializable(v) for k, v in obj.items()}
else:
return obj
str_dict = {
'resource_names':resource_name,
'x':x,
@@ -334,9 +348,10 @@ class JointStatePublisher(Node):
'z':z,
'option':option,
'speed':speed,
'x_joint':x_joint,
'y_joint':y_joint,
'z_joint':z_joint
'x_joint':to_serializable(x_joint),
'y_joint':to_serializable(y_joint),
'z_joint':to_serializable(z_joint),
'channels':to_serializable(channels)
}

View File

@@ -0,0 +1,374 @@
import asyncio
import copy
from pathlib import Path
import threading
import uuid
import rclpy
import json
import time
from rclpy.executors import MultiThreadedExecutor
from rclpy.action import ActionServer,ActionClient
from sensor_msgs.msg import JointState
from unilabos_msgs.action import SendCmd
from rclpy.action.server import ServerGoalHandle
from rclpy.node import Node
import re
class LiquidHandlerJointPublisher(Node):
def __init__(self, joint_config:str = None, lh_device_id: str = 'lh_joint_publisher', rate=50, **kwargs):
super().__init__(lh_device_id)
# 初始化参数
self.lh_device_id = lh_device_id
# INSERT_YOUR_CODE
# 如果未传 joint_config则自动读取同级的 lh_joint_config.json 文件
config_path = Path(__file__).parent / 'lh_joint_config.json'
with open(config_path, 'r', encoding='utf-8') as f:
config_json = json.load(f)
self.joint_config = config_json[joint_config]
self.simulate_rviz = kwargs.get("simulate_rviz", False)
self.rate = rate
self.j_pub = self.create_publisher(JointState,'/joint_states',10)
self.timer = self.create_timer(1, self.lh_joint_pub_callback)
self.resource_action = None
if self.simulate_rviz:
while self.resource_action is None:
self.resource_action = self.check_tf_update_actions()
time.sleep(1)
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
self.get_logger().info('等待 TfUpdate 服务器...')
self.deck_list = []
self.lh_devices = {}
self.j_msg = JointState(
name=[f'{self.lh_device_id}_{x}' for x in self.joint_config['joint_names']],
position=[0.0 for _ in self.joint_config['joint_names']],
velocity=[0.0 for _ in self.joint_config['joint_names']],
effort=[0.0 for _ in self.joint_config['joint_names']]
)
# self.j_action = ActionServer(
# self,
# SendCmd,
# "hl_joint_action",
# self.lh_joint_action_callback,
# result_timeout=5000
# )
def check_tf_update_actions(self):
topics = self.get_topic_names_and_types()
for topic_item in topics:
topic_name, topic_types = topic_item
if 'action_msgs/msg/GoalStatusArray' in topic_types:
# 删除 /_action/status 部分
base_name = topic_name.replace('/_action/status', '')
# 检查最后一个部分是否为 tf_update
parts = base_name.split('/')
if parts and parts[-1] == 'tf_update':
return base_name
return None
def send_resource_action(self, resource_id_list:list[str], link_name:str):
if self.simulate_rviz:
goal_msg = SendCmd.Goal()
str_dict = {}
for resource in resource_id_list:
str_dict[resource] = link_name
goal_msg.command = json.dumps(str_dict)
self.resource_action_client.send_goal(goal_msg)
else:
pass
def resource_move(self, resource_id:str, link_name:str, channels:list[int]):
resource = resource_id.rsplit("_",1)
channel_list = ['A','B','C','D','E','F','G','H']
resource_list = []
match = re.match(r'([a-zA-Z_]+)(\d+)', resource[1])
if match:
number = match.group(2)
for channel in channels:
resource_list.append(f"{resource[0]}_{channel_list[channel]}{number}")
if len(resource_list) > 0:
self.send_resource_action(resource_list, link_name)
def lh_joint_action_callback(self,goal_handle: ServerGoalHandle):
"""Move a single joint
Args:
command: A JSON-formatted string that includes joint_name, speed, position
joint_name (str): The name of the joint to move
speed (float): The speed of the movement, speed > 0
position (float): The position to move to
Returns:
None
"""
result = SendCmd.Result()
cmd_str = str(goal_handle.request.command).replace('\'','\"')
# goal_handle.execute()
try:
cmd_dict = json.loads(cmd_str)
self.move_joints(**cmd_dict)
result.success = True
goal_handle.succeed()
except Exception as e:
print(f'Liquid handler action error: \n{e}')
goal_handle.abort()
result.success = False
return result
def inverse_kinematics(self, x, y, z,
parent_id,
x_joint:dict,
y_joint:dict,
z_joint:dict ):
"""
将x、y、z坐标转换为对应关节的位置
Args:
x (float): x坐标
y (float): y坐标
z (float): z坐标
x_joint (dict): x轴关节配置包含factor和offset
y_joint (dict): y轴关节配置包含factor和offset
z_joint (dict): z轴关节配置包含factor和offset
Returns:
dict: 关节名称和对应位置的字典
"""
joint_positions = copy.deepcopy(self.j_msg.position)
z_index = 0
# 处理x轴关节
for joint_name, config in x_joint.items():
index = self.j_msg.name.index(f"{parent_id}_{joint_name}")
joint_positions[index] = x * config["factor"] + config["offset"]
# 处理y轴关节
for joint_name, config in y_joint.items():
index = self.j_msg.name.index(f"{parent_id}_{joint_name}")
joint_positions[index] = y * config["factor"] + config["offset"]
# 处理z轴关节
for joint_name, config in z_joint.items():
index = self.j_msg.name.index(f"{parent_id}_{joint_name}")
joint_positions[index] = z * config["factor"] + config["offset"]
z_index = index
return joint_positions ,z_index
def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]):
if isinstance(resource_names, list):
resource_name_ = resource_names[0]
else:
resource_name_ = resource_names
lh_device_id = self.lh_device_id
# print('!'*20)
# print(parent_id)
# print('!'*20)
if x_joint is None:
xa,xb = next(iter(self.joint_config['x'].items()))
x_joint_config = {xa:xb}
elif x_joint in self.joint_config['x']:
x_joint_config = self.joint_config['x'][x_joint]
else:
raise ValueError(f"x_joint {x_joint} not in joint_config['x']")
if y_joint is None:
ya,yb = next(iter(self.joint_config['y'].items()))
y_joint_config = {ya:yb}
elif y_joint in self.joint_config['y']:
y_joint_config = self.joint_config['y'][y_joint]
else:
raise ValueError(f"y_joint {y_joint} not in joint_config['y']")
if z_joint is None:
za, zb = next(iter(self.joint_config['z'].items()))
z_joint_config = {za :zb}
elif z_joint in self.joint_config['z']:
z_joint_config = self.joint_config['z'][z_joint]
else:
raise ValueError(f"z_joint {z_joint} not in joint_config['z']")
joint_positions_target, z_index = self.inverse_kinematics(x,y,z,lh_device_id,x_joint_config,y_joint_config,z_joint_config)
joint_positions_target_zero = copy.deepcopy(joint_positions_target)
joint_positions_target_zero[z_index] = 0
self.move_to(joint_positions_target_zero, speed)
self.move_to(joint_positions_target, speed)
time.sleep(1)
if option == "pick":
link_name = self.joint_config['link_names'][z_index]
link_name = f'{lh_device_id}_{link_name}'
self.resource_move(resource_name_, link_name, channels)
elif option == "drop_trash":
self.resource_move(resource_name_, "__trash", channels)
elif option == "drop":
self.resource_move(resource_name_, "world", channels)
self.move_to(joint_positions_target_zero, speed)
def move_to(self, joint_positions ,speed):
loop_flag = 0
while loop_flag < len(joint_positions):
loop_flag = 0
for i in range(len(joint_positions)):
distance = joint_positions[i] - self.j_msg.position[i]
if distance == 0:
loop_flag += 1
continue
minus_flag = distance/abs(distance)
if abs(distance) > speed/self.rate:
self.j_msg.position[i] += minus_flag * speed/self.rate
else :
self.j_msg.position[i] = joint_positions[i]
loop_flag += 1
# 发布关节状态
self.lh_joint_pub_callback()
time.sleep(1/self.rate)
def lh_joint_pub_callback(self):
self.j_msg.header.stamp = self.get_clock().now().to_msg()
self.j_pub.publish(self.j_msg)
class JointStatePublisher(Node):
def __init__(self):
super().__init__('joint_state_publisher')
self.lh_action = None
while self.lh_action is None:
self.lh_action = self.check_hl_joint_actions()
time.sleep(1)
self.lh_action_client = ActionClient(self, SendCmd, self.lh_action)
while not self.lh_action_client.wait_for_server(timeout_sec=1.0):
self.get_logger().info('等待 TfUpdate 服务器...')
def check_hl_joint_actions(self):
topics = self.get_topic_names_and_types()
for topic_item in topics:
topic_name, topic_types = topic_item
if 'action_msgs/msg/GoalStatusArray' in topic_types:
# 删除 /_action/status 部分
base_name = topic_name.replace('/_action/status', '')
# 检查最后一个部分是否为 tf_update
parts = base_name.split('/')
if parts and parts[-1] == 'hl_joint_action':
return base_name
return None
def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]):
goal_msg = SendCmd.Goal()
# Convert numpy arrays or other non-serializable objects to lists
def to_serializable(obj):
if hasattr(obj, 'tolist'): # numpy array
return obj.tolist()
elif isinstance(obj, list):
return [to_serializable(item) for item in obj]
elif isinstance(obj, dict):
return {k: to_serializable(v) for k, v in obj.items()}
else:
return obj
str_dict = {
'resource_names':resource_name,
'x':x,
'y':y,
'z':z,
'option':option,
'speed':speed,
'x_joint':to_serializable(x_joint),
'y_joint':to_serializable(y_joint),
'z_joint':to_serializable(z_joint),
'channels':to_serializable(channels)
}
goal_msg.command = json.dumps(str_dict)
if not self.lh_action_client.wait_for_server(timeout_sec=5.0):
self.get_logger().error('Action server not available')
return None
try:
# 创建新的executor
executor = rclpy.executors.MultiThreadedExecutor()
executor.add_node(self)
# 发送目标
future = self.lh_action_client.send_goal_async(goal_msg)
# 使用executor等待结果
while not future.done():
executor.spin_once(timeout_sec=0.1)
handle = future.result()
if not handle.accepted:
self.get_logger().error('Goal was rejected')
return None
# 等待最终结果
result_future = handle.get_result_async()
while not result_future.done():
executor.spin_once(timeout_sec=0.1)
result = result_future.result()
return result
except Exception as e:
self.get_logger().error(f'Error during action execution: {str(e)}')
return None
finally:
# 清理executor
executor.remove_node(self)
def main():
pass
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,100 @@
import serial, time, re
class SimpleSealer:
"""
It purposely skips CRC/ACK handling and sends raw commands of the form
'**00??[=xxxx]zz!'. Good enough for quick experiments or automation
scripts where robustness is less critical.
Example
-------
>>> sealer = SimpleSealer("COM24")
>>> sealer.set_temperature(160) # 160°C
>>> sealer.set_time(2.0) # 2s
>>> sealer.seal_cycle() # waitheatsealeject
>>> sealer.close()
"""
T_RE = re.compile(r"\*T\d\d:\d\d:\d\d=(\d+),(\d),(\d),")
def __init__(self, port: str, baud: int = 19200, thresh_c: int = 150):
self.port = port
self.baud = baud
self.thresh_c = thresh_c
self.ser = serial.Serial(port, baud, timeout=0.3)
self.ser.reset_input_buffer()
# ---------- lowlevel helpers ----------
def _send(self, raw: str):
"""Write an alreadyformed ASCII command, e.g. '**00DH=0160zz!'."""
self.ser.write(raw.encode())
print(">>>", raw)
def _read_frame(self) -> str:
"""Read one frame (ending at '!') and strip the terminator."""
return self.ser.read_until(b'!').decode(errors='ignore').strip()
# ---------- highlevel commands ----------
def set_temperature(self, celsius: int):
self._send(f"**00DH={celsius:04d}zz!")
def set_time(self, seconds: float):
units = int(round(seconds * 10))
self._send(f"**00DT={units:04d}zz!")
def open_drawer(self):
self._send("**00MOzz!")
def close_drawer(self):
self._send("**00MCzz!")
def seal(self):
self._send("**00GSzz!")
# ---------- waits ----------
def wait_temp(self):
print(f"[Waiting ≥{self.thresh_c}°C]")
while True:
frame = self._read_frame()
if frame.startswith("*T"):
m = self.T_RE.match(frame)
if not m:
continue
temp = int(m.group(1)) / 10
blk = int(m.group(3)) # 1=Ready,4=Converging
print(f"\rTemp={temp:5.1f}°C | Block={blk}", end="")
if temp >= self.thresh_c and blk in (1, 0, 4):
print(" <-- OK")
return
def wait_finish(self):
while True:
frame = self._read_frame()
if frame.startswith("*T"):
parts = frame.split('=')[1].split(',')
status = int(parts[1])
cnt = int(parts[6][: -3] if parts[6].endswith("!") else parts[6])
print(f"\rRemaining {cnt/10:4.1f}s", end="")
if status == 4:
print("\n[Seal Done]")
return
# ---------- convenience helpers ----------
def seal_cycle(self):
"""Full cycle: wait heat, seal, wait finish, eject drawer."""
time.sleep(10)
self.seal()
self.open_drawer()
def close(self):
self.ser.close()
if __name__ == "__main__":
# Quick demo usage (modify COM port and parameters as needed)
sealer = SimpleSealer("COM24")
try:
sealer.set_temperature(160) # °C
sealer.set_time(2.0) # seconds
sealer.seal_cycle()
finally:
sealer.close()

View File

@@ -7,7 +7,7 @@ class VirtualMultiwayValve:
"""
虚拟九通阀门 - 0号位连接transfer pump1-8号位连接其他设备 🔄
"""
def __init__(self, port: str = "VIRTUAL", positions: int = 8):
def __init__(self, port: str = "VIRTUAL", positions: int = 8, **kwargs):
self.port = port
self.max_positions = positions # 1-8号位
self.total_positions = positions + 1 # 0-8号位共9个位置

View File

@@ -17,7 +17,7 @@ API_CONFIG = {
"report_token": os.getenv("BIOYOND_REPORT_TOKEN", "CHANGE_ME_TOKEN"),
# HTTP 服务配置
"HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.16.11.2"), # HTTP服务监听地址监听计算机飞连ip地址
"HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.16.11.6"), # HTTP服务监听地址监听计算机飞连ip地址
"HTTP_port": int(os.getenv("BIOYOND_HTTP_PORT", "8080")),
"debug_mode": False,# 调试模式
}

View File

@@ -0,0 +1,59 @@
import serial
import time
class SealRemoverController:
def __init__(self, port='COM17', baudrate=9600, timeout=2):
self.ser = serial.Serial(port, baudrate, timeout=timeout)
def send_command(self, command):
full_cmd = f"{command}\r\n".encode('ascii')
self.ser.write(full_cmd)
time.sleep(0.5) # 稍等设备响应
return self.read_response()
def read_response(self):
lines = []
while self.ser.in_waiting:
line = self.ser.readline().decode('ascii').strip()
lines.append(line)
return lines
def reset(self):
return self.send_command("*reset")
def restart(self):
return self.send_command("*restart")
def check_status(self):
return self.send_command("*stat")
def move_in(self):
return self.send_command("*movein")
def move_out(self):
return self.send_command("*moveout")
def move_up(self):
return self.send_command("*moveup")
def move_down(self):
return self.send_command("*movedown")
def peel(self, param_set=1, adhere_time=1):
# param_set: 1~9, adhere_time: 1(2.5s) ~ 4(10s)
return self.send_command(f"*xpeel:{param_set}{adhere_time}")
def check_tape(self):
return self.send_command("*tapeleft")
def close(self):
self.ser.close()
if __name__ == "__main__":
remover = SealRemoverController(port='COM17')
remover.restart # "restart"
remover.check_status() # "检查状态:"
remover.reset() # 复位设备
remover.peel(param_set=4, adhere_time=1) #执行撕膜操作,慢速+2.5s
remover.move_out() # 送出板子
remover.close()

View File

@@ -0,0 +1,26 @@
{
"nodes": [
{
"id": "XRD_D7MATE_STATION",
"name": "XRD_D7MATE",
"parent": null,
"type": "device",
"class": "xrd_d7mate",
"position": {
"x": 720.0,
"y": 200.0,
"z": 0
},
"config": {
"host": "127.0.0.1",
"port": 6001,
"timeout": 10.0
},
"data": {
"input_hint": "start 支持单字符串输入:'sample_name 样品A start_theta 10.0 end_theta 80.0 increment 0.02 exp_time 0.1 [wait_minutes 3]';也支持等号形式 'sample_id=样品A start_theta=10.0 end_theta=80.0 increment=0.02 exp_time=0.1 wait_minutes=3'"
},
"children": []
}
],
"links": []
}

View File

@@ -0,0 +1,939 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XRD D7-Mate设备驱动
支持XRD D7-Mate设备的TCP通信协议包括自动模式控制、上样流程、数据获取、下样流程和高压电源控制等功能。
通信协议版本1.0.0
"""
import json
import socket
import struct
import time
from typing import Dict, List, Optional, Tuple, Any, Union
class XRDClient:
def __init__(self, host='127.0.0.1', port=6001, timeout=10.0):
"""
初始化XRD D7-Mate客户端
Args:
host (str): 设备IP地址
port (int): 通信端口默认6001
timeout (float): 超时时间,单位秒
"""
self.host = host
self.port = port
self.timeout = timeout
self.sock = None
self._ros_node = None # ROS节点引用由框架设置
def post_init(self, ros_node):
"""
ROS节点初始化后的回调方法保存ROS节点引用但不自动连接
Args:
ros_node: ROS节点实例
"""
self._ros_node = ros_node
ros_node.lab_logger().info(f"XRD D7-Mate设备已初始化将在需要时连接: {self.host}:{self.port}")
# 不自动连接,只有在调用具体功能时才建立连接
def connect(self):
"""
建立TCP连接到XRD D7-Mate设备
Raises:
ConnectionError: 连接失败时抛出
"""
try:
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
self.sock.settimeout(self.timeout)
except Exception as e:
raise ConnectionError(f"Failed to connect to {self.host}:{self.port} - {str(e)}")
def close(self):
"""
关闭与XRD D7-Mate设备的TCP连接
"""
if self.sock:
try:
self.sock.close()
except Exception:
pass # 忽略关闭时的错误
finally:
self.sock = None
def _ensure_connection(self) -> bool:
"""
确保连接存在,如果不存在则尝试建立连接
Returns:
bool: 连接是否成功建立
"""
if self.sock is None:
try:
self.connect()
return True
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"建立连接失败: {e}")
return False
return True
def _receive_with_length_prefix(self) -> dict:
"""
使用长度前缀协议接收数据
Returns:
dict: 解析后的JSON响应数据
Raises:
ConnectionError: 连接错误
TimeoutError: 超时错误
"""
try:
# 首先接收4字节的长度信息
length_data = bytearray()
while len(length_data) < 4:
chunk = self.sock.recv(4 - len(length_data))
if not chunk:
raise ConnectionError("Connection closed while receiving length prefix")
length_data.extend(chunk)
# 解析长度(大端序无符号整数)
data_length = struct.unpack('>I', length_data)[0]
if self._ros_node:
self._ros_node.lab_logger().info(f"接收到数据长度: {data_length} 字节")
# 根据长度接收实际数据
json_data = bytearray()
while len(json_data) < data_length:
remaining = data_length - len(json_data)
chunk = self.sock.recv(min(4096, remaining))
if not chunk:
raise ConnectionError("Connection closed while receiving JSON data")
json_data.extend(chunk)
# 解码JSON数据优先使用UTF-8失败时尝试GBK
try:
json_str = json_data.decode('utf-8')
except UnicodeDecodeError:
json_str = json_data.decode('gbk')
# 解析JSON
result = json.loads(json_str)
if self._ros_node:
self._ros_node.lab_logger().info(f"成功解析JSON响应: {result}")
return result
except socket.timeout:
if self._ros_node:
self._ros_node.lab_logger().warning(f"接收超时")
raise TimeoutError(f"recv() timed out after {self.timeout:.1f}s")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"接收数据失败: {e}")
raise ConnectionError(f"Failed to receive data: {str(e)}")
def _send_command(self, cmd: dict) -> dict:
"""
使用长度前缀协议发送命令到XRD D7-Mate设备并接收响应
Args:
cmd (dict): 要发送的命令字典
Returns:
dict: 设备响应的JSON数据
Raises:
ConnectionError: 连接错误
TimeoutError: 超时错误
"""
# 确保连接存在,如果不存在则建立连接
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info(f"为命令重新建立连接")
except Exception as e:
raise ConnectionError(f"Failed to establish connection: {str(e)}")
try:
# 序列化命令为JSON
json_str = json.dumps(cmd, ensure_ascii=False)
payload = json_str.encode('utf-8')
# 计算JSON数据长度并打包为4字节大端序无符号整数
length_prefix = struct.pack('>I', len(payload))
if self._ros_node:
self._ros_node.lab_logger().info(f"发送JSON命令到XRD D7-Mate: {json_str}")
self._ros_node.lab_logger().info(f"发送数据长度: {len(payload)} 字节")
# 发送长度前缀
self.sock.sendall(length_prefix)
# 发送JSON数据
self.sock.sendall(payload)
# 使用长度前缀协议接收响应
response = self._receive_with_length_prefix()
return response
except Exception as e:
# 如果是连接错误,尝试重新连接一次
if "远程主机强迫关闭了一个现有的连接" in str(e) or "10054" in str(e):
if self._ros_node:
self._ros_node.lab_logger().warning(f"连接被远程主机关闭,尝试重新连接: {e}")
try:
self.close()
self.connect()
# 重新发送命令
json_str = json.dumps(cmd, ensure_ascii=False)
payload = json_str.encode('utf-8')
if self._ros_node:
self._ros_node.lab_logger().info(f"重新发送JSON命令到XRD D7-Mate: {json_str}")
self.sock.sendall(payload)
# 重新接收响应
buffer = bytearray()
start = time.time()
while True:
try:
chunk = self.sock.recv(4096)
if not chunk:
break
buffer.extend(chunk)
# 尝试解码和解析JSON
try:
text = buffer.decode('utf-8', errors='strict')
text = text.strip()
if text.startswith('{'):
brace_count = 0
json_end = -1
for i, char in enumerate(text):
if char == '{':
brace_count += 1
elif char == '}':
brace_count -= 1
if brace_count == 0:
json_end = i + 1
break
if json_end > 0:
text = text[:json_end]
result = json.loads(text)
if self._ros_node:
self._ros_node.lab_logger().info(f"重连后成功解析JSON响应: {result}")
return result
except (UnicodeDecodeError, json.JSONDecodeError):
pass
except socket.timeout:
if self._ros_node:
self._ros_node.lab_logger().warning(f"重连后接收超时")
raise TimeoutError(f"recv() timed out after reconnection")
if time.time() - start > self.timeout * 2:
raise TimeoutError(f"No complete JSON received after reconnection")
except Exception as retry_e:
if self._ros_node:
self._ros_node.lab_logger().error(f"重连失败: {retry_e}")
raise ConnectionError(f"Connection retry failed: {str(retry_e)}")
if isinstance(e, (ConnectionError, TimeoutError)):
raise
else:
raise ConnectionError(f"Command send failed: {str(e)}")
# ==================== 自动模式控制 ====================
def start_auto_mode(self, status: bool) -> dict:
"""
启动或停止自动模式
Args:
status (bool): True-启动自动模式False-停止自动模式
Returns:
dict: 响应结果包含status、timestamp、message
"""
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
# 按协议要求content 直接为布尔值使用传入的status参数
cmd = {
"command": "START_AUTO_MODE",
"content": {
"status": bool(True)
}
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送自动模式控制命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到自动模式控制响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"自动模式控制失败: {e}")
return {"status": False, "message": f"自动模式控制失败: {str(e)}"}
# ==================== 上样流程 ====================
def get_sample_request(self) -> dict:
"""
上样请求,检查是否允许上样
Returns:
dict: 响应结果包含status、timestamp、message
"""
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
cmd = {
"command": "GET_SAMPLE_REQUEST",
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送上样请求命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到上样请求响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"上样请求失败: {e}")
return {"status": False, "message": f"上样请求失败: {str(e)}"}
def send_sample_ready(self, sample_id: str, start_theta: float, end_theta: float,
increment: float, exp_time: float) -> dict:
"""
送样完成后,发送样品信息和采集参数
Args:
sample_id (str): 样品标识符
start_theta (float): 起始角度≥5°
end_theta (float): 结束角度≥5.5°且必须大于start_theta
increment (float): 角度增量≥0.005
exp_time (float): 曝光时间0.1-5.0秒)
Returns:
dict: 响应结果包含status、timestamp、message等
"""
# 参数验证
if start_theta < 5.0:
return {"status": False, "message": "起始角度必须≥5°"}
if end_theta < 5.5:
return {"status": False, "message": "结束角度必须≥5.5°"}
if end_theta <= start_theta:
return {"status": False, "message": "结束角度必须大于起始角度"}
if increment < 0.005:
return {"status": False, "message": "角度增量必须≥0.005"}
if not (0.1 <= exp_time <= 5.0):
return {"status": False, "message": "曝光时间必须在0.1-5.0秒之间"}
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
cmd = {
"command": "SEND_SAMPLE_READY",
"content": {
"sample_id": sample_id,
"start_theta": start_theta,
"end_theta": end_theta,
"increment": increment,
"exp_time": exp_time
}
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送样品准备完成命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到样品准备完成响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"样品准备完成失败: {e}")
return {"status": False, "message": f"样品准备完成失败: {str(e)}"}
# ==================== 数据获取 ====================
def get_current_acquire_data(self) -> dict:
"""
获取当前正在采集的样品数据
Returns:
dict: 响应结果包含status、timestamp、sample_id、Energy、Intensity等
"""
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
cmd = {
"command": "GET_CURRENT_ACQUIRE_DATA",
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送获取采集数据命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到获取采集数据响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"获取采集数据失败: {e}")
return {"status": False, "message": f"获取采集数据失败: {str(e)}"}
def get_sample_status(self) -> dict:
"""
获取工位样品状态及设备状态
Returns:
dict: 响应结果包含status、timestamp、Station等
"""
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
cmd = {
"command": "GET_SAMPLE_STATUS",
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送获取样品状态命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到获取样品状态响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"获取样品状态失败: {e}")
return {"status": False, "message": f"获取样品状态失败: {str(e)}"}
# ==================== 下样流程 ====================
def get_sample_down(self, sample_station: int) -> dict:
"""
下样请求
Args:
sample_station (int): 下样工位1, 2, 3
Returns:
dict: 响应结果包含status、timestamp、sample_info等
"""
# 参数验证
if sample_station not in [1, 2, 3]:
return {"status": False, "message": "下样工位必须是1、2或3"}
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
# 按协议要求content 直接为整数工位号
cmd = {
"command": "GET_SAMPLE_DOWN",
"content": {
"Sample station":int(3)
}
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送下样请求命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到下样请求响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"下样请求失败: {e}")
return {"status": False, "message": f"下样请求失败: {str(e)}"}
def send_sample_down_ready(self) -> dict:
"""
下样完成命令
Returns:
dict: 响应结果包含status、timestamp、message
"""
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
cmd = {
"command": "SEND_SAMPLE_DOWN_READY",
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送下样完成命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到下样完成响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"下样完成失败: {e}")
return {"status": False, "message": f"下样完成失败: {str(e)}"}
# ==================== 高压电源控制 ====================
def set_power_on(self) -> dict:
"""
高压电源开启
Returns:
dict: 响应结果包含status、timestamp、message
"""
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
cmd = {
"command": "SET_POWER_ON",
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送高压电源开启命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到高压开启响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"高压开启失败: {e}")
return {"status": False, "message": f"高压开启失败: {str(e)}"}
def set_power_off(self) -> dict:
"""
高压电源关闭
Returns:
dict: 响应结果包含status、timestamp、message
"""
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
cmd = {
"command": "SET_POWER_OFF",
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送高压电源关闭命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到高压关闭响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"高压关闭失败: {e}")
return {"status": False, "message": f"高压关闭失败: {str(e)}"}
def set_voltage_current(self, voltage: float, current: float) -> dict:
"""
设置高压电源电压和电流
Args:
voltage (float): 电压值kV
current (float): 电流值mA
Returns:
dict: 响应结果包含status、timestamp、message
"""
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}")
return {"status": False, "message": "设备连接异常"}
try:
cmd = {
"command": "SET_VOLTAGE_CURRENT",
"content": {
"voltage": voltage,
"current": current
}
}
if self._ros_node:
self._ros_node.lab_logger().info(f"发送设置电压电流命令: {cmd}")
response = self._send_command(cmd)
if self._ros_node:
self._ros_node.lab_logger().info(f"收到设置电压电流响应: {response}")
return response
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"设置电压电流失败: {e}")
return {"status": False, "message": f"设置电压电流失败: {str(e)}"}
def start(self, sample_id: str = "", start_theta: float = 10.0, end_theta: float = 80.0,
increment: float = 0.05, exp_time: float = 0.1, wait_minutes: float = 3.0,
string: str = "") -> dict:
"""
Start 主流程:
1) 启动自动模式;
2) 发送上样请求并等待允许;
3) 等待指定分钟后发送样品准备完成(携带采集参数);
4) 周期性轮询采集数据与工位状态;
5) 一旦任一下样位变为 True执行下样流程GET_SAMPLE_DOWN + SEND_SAMPLE_DOWN_READY
Args:
sample_id: 样品名称
start_theta: 起始角度≥5°
end_theta: 结束角度≥5.5°,且必须大于 start_theta
increment: 角度增量≥0.005
exp_time: 曝光时间0.1-5.0 秒)
wait_minutes: 在允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟)
string: 字符串格式的参数输入,如果提供则优先解析使用
Returns:
dict: {"return_info": str, "success": bool}
"""
try:
# 强制类型转换:除 sample_id 外的所有输入均转换为 float若为字符串
def _to_float(v, default):
try:
return float(v)
except (TypeError, ValueError):
return float(default)
if not isinstance(sample_id, str):
sample_id = str(sample_id)
if isinstance(start_theta, str):
start_theta = _to_float(start_theta, 10.0)
if isinstance(end_theta, str):
end_theta = _to_float(end_theta, 80.0)
if isinstance(increment, str):
increment = _to_float(increment, 0.05)
if isinstance(exp_time, str):
exp_time = _to_float(exp_time, 0.1)
if isinstance(wait_minutes, str):
wait_minutes = _to_float(wait_minutes, 3.0)
# 不再从 string 参数解析覆盖;保留参数但忽略字符串解析,统一使用结构化输入
# 确保设备连接
if not self.sock:
try:
self.connect()
if self._ros_node:
self._ros_node.lab_logger().info("XRD D7-Mate设备连接成功开始执行start流程")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"XRD D7-Mate设备连接失败: {e}")
return {"return_info": f"设备连接失败: {str(e)}", "success": False}
# 1) 启动自动模式
r_auto = self.start_auto_mode(True)
if not r_auto.get("status", False):
return {"return_info": f"启动自动模式失败: {r_auto.get('message', '未知')}", "success": False}
if self._ros_node:
self._ros_node.lab_logger().info(f"自动模式已启动: {r_auto}")
# 2) 上样请求
r_req = self.get_sample_request()
if not r_req.get("status", False):
return {"return_info": f"上样请求未允许: {r_req.get('message', '未知')}", "success": False}
if self._ros_node:
self._ros_node.lab_logger().info(f"上样已允许: {r_req}")
# 3) 等待指定分钟后发送样品准备完成
wait_seconds = max(0.0, float(wait_minutes)) * 60.0
if self._ros_node:
self._ros_node.lab_logger().info(f"等待 {wait_minutes} 分钟后发送样品准备完成")
time.sleep(wait_seconds)
r_ready = self.send_sample_ready(sample_id=sample_id,
start_theta=start_theta,
end_theta=end_theta,
increment=increment,
exp_time=exp_time)
if not r_ready.get("status", False):
return {"return_info": f"样品准备完成失败: {r_ready.get('message', '未知')}", "success": False}
if self._ros_node:
self._ros_node.lab_logger().info(f"样品准备完成已发送: {r_ready}")
# 4) 轮询采集数据与工位状态
polling_interval = 5.0 # 秒
down_station_idx: Optional[int] = None
while True:
try:
r_data = self.get_current_acquire_data()
if self._ros_node:
self._ros_node.lab_logger().info(f"采集中数据: {r_data}")
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"获取采集数据失败: {e}")
try:
r_status = self.get_sample_status()
if self._ros_node:
self._ros_node.lab_logger().info(f"工位状态: {r_status}")
station = r_status.get("Station", {})
if isinstance(station, dict):
for idx in (1, 2, 3):
key = f"DownStation{idx}"
val = station.get(key)
if isinstance(val, bool) and val:
down_station_idx = idx
break
if down_station_idx is not None:
break
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().warning(f"获取工位状态失败: {e}")
time.sleep(polling_interval)
if down_station_idx is None:
return {"return_info": "未检测到任一下样位 True流程未完成", "success": False}
# 5) 下样流程
r_down = self.get_sample_down(down_station_idx)
if not r_down.get("status", False):
return {"return_info": f"下样请求失败(工位 {down_station_idx}): {r_down.get('message', '未知')}", "success": False}
if self._ros_node:
self._ros_node.lab_logger().info(f"下样请求成功(工位 {down_station_idx}): {r_down}")
r_ready_down = self.send_sample_down_ready()
if not r_ready_down.get("status", False):
return {"return_info": f"下样完成发送失败: {r_ready_down.get('message', '未知')}", "success": False}
if self._ros_node:
self._ros_node.lab_logger().info(f"下样完成已发送: {r_ready_down}")
return {"return_info": f"Start流程完成工位 {down_station_idx} 已下样", "success": True}
except Exception as e:
if self._ros_node:
self._ros_node.lab_logger().error(f"Start流程异常: {e}")
return {"return_info": f"Start流程异常: {str(e)}", "success": False}
def _parse_start_params(self, params: Union[str, Dict[str, Any]]) -> Dict[str, Any]:
"""
解析UI输入参数为 Start 流程参数。
- 从UI字典中读取各个字段的字符串值
- 将数值字段从字符串转换为 float 类型
- 保留 sample_id 为字符串类型
返回:
dict: {sample_id, start_theta, end_theta, increment, exp_time, wait_minutes}
"""
# 如果传入为字典,则直接按键读取;否则给出警告并使用空字典
if isinstance(params, dict):
p = params
else:
p = {}
if self._ros_node:
self._ros_node.lab_logger().warning("start 参数应为结构化字典")
def _to_float(v, default):
"""将UI输入的字符串值转换为float处理空值和无效值"""
if v is None or v == '':
return float(default)
try:
# 处理字符串输入来自UI
if isinstance(v, str):
v = v.strip()
if v == '':
return float(default)
return float(v)
except (TypeError, ValueError):
return float(default)
# 从UI输入字典中读取参数
sample_id = p.get('sample_id') or p.get('sample_name') or '样品名称'
if not isinstance(sample_id, str):
sample_id = str(sample_id)
# 将UI字符串输入转换为float
result: Dict[str, Any] = {
'sample_id': sample_id,
'start_theta': _to_float(p.get('start_theta'), 10.0),
'end_theta': _to_float(p.get('end_theta'), 80.0),
'increment': _to_float(p.get('increment'), 0.05),
'exp_time': _to_float(p.get('exp_time'), 0.1),
'wait_minutes': _to_float(p.get('wait_minutes'), 3.0),
}
return result
def start_from_string(self, params: Union[str, Dict[str, Any]]) -> dict:
"""
从UI输入参数执行 Start 主流程。
接收来自用户界面的参数字典,其中数值字段为字符串格式,自动转换为正确的类型。
参数:
params: UI输入参数字典例如:
{
'sample_id': 'teste',
'start_theta': '10.0', # UI字符串输入
'end_theta': '25.0', # UI字符串输入
'increment': '0.05', # UI字符串输入
'exp_time': '0.10', # UI字符串输入
'wait_minutes': '0.5' # UI字符串输入
}
返回:
dict: 执行结果
"""
parsed = self._parse_start_params(params)
sample_id = parsed.get('sample_id', '样品名称')
start_theta = float(parsed.get('start_theta', 10.0))
end_theta = float(parsed.get('end_theta', 80.0))
increment = float(parsed.get('increment', 0.05))
exp_time = float(parsed.get('exp_time', 0.1))
wait_minutes = float(parsed.get('wait_minutes', 3.0))
return self.start(
sample_id=sample_id,
start_theta=start_theta,
end_theta=end_theta,
increment=increment,
exp_time=exp_time,
wait_minutes=wait_minutes,
)
# 测试函数
def test_xrd_client():
"""
测试XRD客户端功能
"""
client = XRDClient(host='127.0.0.1', port=6001)
try:
# 测试连接
client.connect()
print("连接成功")
# 测试启动自动模式
result = client.start_auto_mode(True)
print(f"启动自动模式: {result}")
# 测试上样请求
result = client.get_sample_request()
print(f"上样请求: {result}")
# 测试获取样品状态
result = client.get_sample_status()
print(f"样品状态: {result}")
# 测试高压开启
result = client.set_power_on()
print(f"高压开启: {result}")
except Exception as e:
print(f"测试失败: {e}")
finally:
client.close()
if __name__ == "__main__":
test_xrd_client()
# 为了兼容性,提供别名
XRD_D7Mate = XRDClient