mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-19 22:11:20 +00:00
Merge dev branch: Add battery resources, bioyond_cell device registry, and fix file path resolution
This commit is contained in:
200
unilabos/devices/Qone_nmr/QOne_NMR_User_Guide.md
Normal file
200
unilabos/devices/Qone_nmr/QOne_NMR_User_Guide.md
Normal 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文件的完成状态,适合批量处理场景
|
||||
382
unilabos/devices/Qone_nmr/Qone_nmr.py
Normal file
382
unilabos/devices/Qone_nmr/Qone_nmr.py
Normal 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()
|
||||
25
unilabos/devices/Qone_nmr/device.json
Normal file
25
unilabos/devices/Qone_nmr/device.json
Normal 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": []
|
||||
}
|
||||
4
unilabos/devices/Qone_nmr/samples.csv
Normal file
4
unilabos/devices/Qone_nmr/samples.csv
Normal 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
|
||||
|
61
unilabos/devices/cytomat/cytomat.py
Normal file
61
unilabos/devices/cytomat/cytomat.py
Normal 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: 1–9999 绝对地址。"""
|
||||
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.")
|
||||
|
||||
@@ -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)})
|
||||
|
||||
0
unilabos/devices/laiyu_liquid_test/__init__.py
Normal file
0
unilabos/devices/laiyu_liquid_test/__init__.py
Normal file
138
unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py
Normal file
138
unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py
Normal 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()
|
||||
58
unilabos/devices/laiyu_liquid_test/driver_status_test.py
Normal file
58
unilabos/devices/laiyu_liquid_test/driver_status_test.py
Normal 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()
|
||||
8
unilabos/devices/laiyu_liquid_test/work_origin.json
Normal file
8
unilabos/devices/laiyu_liquid_test/work_origin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"work_origin_steps": {
|
||||
"x": 11799,
|
||||
"y": 11476,
|
||||
"z": 3312
|
||||
},
|
||||
"timestamp": "2025-11-04T15:31:09.802155"
|
||||
}
|
||||
336
unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py
Normal file
336
unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py
Normal 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")
|
||||
|
||||
|
||||
# ========== 层 1:Modbus 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)
|
||||
|
||||
|
||||
# ========== 层 2:Modbus 协议 ==========
|
||||
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("🎯 回软零点完成 ✅")
|
||||
0
unilabos/devices/liquid_handling/laiyu/__init__.py
Normal file
0
unilabos/devices/liquid_handling/laiyu/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端模块
|
||||
|
||||
提供设备后端接口和实现
|
||||
"""
|
||||
|
||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||
|
||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||
334
unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py
Normal file
334
unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
2620
unilabos/devices/liquid_handling/laiyu/config/deckconfig.json
Normal file
2620
unilabos/devices/liquid_handling/laiyu/config/deckconfig.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 高级控制器集合"
|
||||
@@ -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
1253
unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py
Normal file
1253
unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
881
unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py
Normal file
881
unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py
Normal 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"
|
||||
]
|
||||
42
unilabos/devices/liquid_handling/laiyu/core/__init__.py
Normal file
42
unilabos/devices/liquid_handling/laiyu/core/__init__.py
Normal 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'
|
||||
]
|
||||
529
unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py
Normal file
529
unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py
Normal 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
|
||||
954
unilabos/devices/liquid_handling/laiyu/core/laiyu_liquid_res.py
Normal file
954
unilabos/devices/liquid_handling/laiyu/core/laiyu_liquid_res.py
Normal 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
|
||||
69
unilabos/devices/liquid_handling/laiyu/docs/CHANGELOG.md
Normal file
69
unilabos/devices/liquid_handling/laiyu/docs/CHANGELOG.md
Normal 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**: 向后兼容的问题修复
|
||||
|
||||
### 变更类型
|
||||
- **新增功能**: 新的功能特性
|
||||
- **变更**: 现有功能的变更
|
||||
- **弃用**: 即将移除的功能
|
||||
- **移除**: 已移除的功能
|
||||
- **修复**: 问题修复
|
||||
- **安全**: 安全相关的修复
|
||||
@@ -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(注意:地址不可为47,69,91)
|
||||
- **命令/数据**: 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
|
||||
# 示例:设置最高速度为2000(200ul/秒)
|
||||
s2000E
|
||||
|
||||
# 设置启动速度
|
||||
b[n]E
|
||||
# 示例:设置启动速度为100(10ul/秒)
|
||||
b100E
|
||||
|
||||
# 设置断流速度
|
||||
c[n]E
|
||||
# 示例:设置断流速度为100(10ul/秒)
|
||||
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
|
||||
162
unilabos/devices/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md
Normal file
162
unilabos/devices/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md
Normal 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反向
|
||||
1281
unilabos/devices/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md
Normal file
1281
unilabos/devices/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md
Normal file
File diff suppressed because it is too large
Load Diff
285
unilabos/devices/liquid_handling/laiyu/docs/readme.md
Normal file
285
unilabos/devices/liquid_handling/laiyu/docs/readme.md
Normal 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.
|
||||
30
unilabos/devices/liquid_handling/laiyu/drivers/__init__.py
Normal file
30
unilabos/devices/liquid_handling/laiyu/drivers/__init__.py
Normal 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
@@ -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
|
||||
218
unilabos/devices/liquid_handling/laiyu/laiyu.py
Normal file
218
unilabos/devices/liquid_handling/laiyu/laiyu.py
Normal 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
|
||||
|
||||
13
unilabos/devices/liquid_handling/laiyu/tests/__init__.py
Normal file
13
unilabos/devices/liquid_handling/laiyu/tests/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LaiYu液体处理设备测试模块
|
||||
|
||||
该模块包含LaiYu液体处理设备的测试用例:
|
||||
- test_deck_config.py: 工作台配置测试
|
||||
|
||||
作者: UniLab团队
|
||||
版本: 2.0.0
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
315
unilabos/devices/liquid_handling/laiyu/tests/test_deck_config.py
Normal file
315
unilabos/devices/liquid_handling/laiyu/tests/test_deck_config.py
Normal 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)
|
||||
@@ -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
|
||||
Same‑length sequences of containers (wells or plates). In 96‑well 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 96‑channel 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()
|
||||
|
||||
@@ -41151,5 +41151,6 @@
|
||||
"uuid": "730067cf07ae43849ddf4034299030e9"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
21
unilabos/devices/liquid_handling/prcxi/prcxi_materials.py
Normal file
21
unilabos/devices/liquid_handling/prcxi/prcxi_materials.py
Normal 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
|
||||
|
||||
|
||||
@@ -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}.")
|
||||
|
||||
20
unilabos/devices/opsky_Raman/devices.json
Normal file
20
unilabos/devices/opsky_Raman/devices.json
Normal 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": []
|
||||
}
|
||||
71
unilabos/devices/opsky_Raman/dmqfengzhuang.py
Normal file
71
unilabos/devices/opsky_Raman/dmqfengzhuang.py
Normal 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
|
||||
398
unilabos/devices/opsky_Raman/opsky_ATR30007.py
Normal file
398
unilabos/devices/opsky_Raman/opsky_ATR30007.py
Normal 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
|
||||
180
unilabos/devices/opsky_Raman/raman_module.py
Normal file
180
unilabos/devices/opsky_Raman/raman_module.py
Normal 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
|
||||
209
unilabos/devices/opsky_Raman/test2.py
Normal file
209
unilabos/devices/opsky_Raman/test2.py
Normal 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("✅ 程序已退出,设备全部复位。")
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
374
unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py
Normal file
374
unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py
Normal 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()
|
||||
100
unilabos/devices/sealer/sealer.py
Normal file
100
unilabos/devices/sealer/sealer.py
Normal 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) # 2 s
|
||||
>>> sealer.seal_cycle() # wait‑heat‑seal‑eject
|
||||
>>> 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()
|
||||
|
||||
# ---------- low‑level helpers ----------
|
||||
def _send(self, raw: str):
|
||||
"""Write an already‑formed 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()
|
||||
|
||||
# ---------- high‑level 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()
|
||||
@@ -7,7 +7,7 @@ class VirtualMultiwayValve:
|
||||
"""
|
||||
虚拟九通阀门 - 0号位连接transfer pump,1-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个位置
|
||||
|
||||
@@ -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,# 调试模式
|
||||
}
|
||||
|
||||
59
unilabos/devices/xpeel/xpeel.py
Normal file
59
unilabos/devices/xpeel/xpeel.py
Normal 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()
|
||||
26
unilabos/devices/xrd_d7mate/device.json
Normal file
26
unilabos/devices/xrd_d7mate/device.json
Normal 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": []
|
||||
}
|
||||
939
unilabos/devices/xrd_d7mate/xrd_d7mate.py
Normal file
939
unilabos/devices/xrd_d7mate/xrd_d7mate.py
Normal 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
|
||||
Reference in New Issue
Block a user