diff --git a/UniLab_LaiYu_控制架构详解.md b/UniLab_LaiYu_控制架构详解.md
new file mode 100644
index 00000000..efa3333f
--- /dev/null
+++ b/UniLab_LaiYu_控制架构详解.md
@@ -0,0 +1,259 @@
+# UniLab 控制 LaiYu_Liquid 设备架构详解
+
+## 概述
+
+UniLab 通过分层架构控制 LaiYu_Liquid 设备,实现了从高级实验协议到底层硬件驱动的完整控制链路。
+
+## 🏗️ 架构层次
+
+### 1. 应用层 (Application Layer)
+- **实验协议**: 用户定义的实验流程
+- **设备抽象**: 通过 `LaiYu_Liquid` 类提供统一接口
+
+### 2. 控制层 (Control Layer)
+- **LaiYuLiquidBackend**: 设备业务逻辑控制器
+- **PipetteController**: 移液器控制器
+- **XYZController**: 三轴运动控制器
+
+### 3. 驱动层 (Driver Layer)
+- **SOPAPipetteDriver**: SOPA 移液器驱动
+- **XYZStepperDriver**: 三轴步进电机驱动
+
+### 4. 硬件层 (Hardware Layer)
+- **串口通信**: 通过 `/dev/cu.usbserial-3130` 等串口设备
+- **物理设备**: 移液器和三轴运动平台
+
+## 🔧 核心组件详解
+
+### LaiYu_Liquid 主类
+```python
+class LaiYu_Liquid:
+ """LaiYu液体处理设备的主要接口类"""
+
+ def __init__(self, config: LaiYuLiquidConfig):
+ self.config = config
+ self.deck = LaiYuLiquidDeck(config)
+ self.backend = LaiYuLiquidBackend(config, self.deck)
+```
+
+**核心功能**:
+- 设备配置管理
+- 工作台资源管理
+- 硬件控制接口封装
+
+### LaiYuLiquidBackend 控制器
+```python
+class LaiYuLiquidBackend:
+ """设备后端控制逻辑"""
+
+ async def setup(self):
+ """初始化硬件控制器"""
+ # 初始化移液器控制器
+ self.pipette_controller = PipetteController(
+ port=self.config.port,
+ address=self.config.address
+ )
+
+ # 初始化XYZ控制器
+ self.xyz_controller = XYZController(
+ port=self.config.port,
+ baudrate=self.config.baudrate,
+ machine_config=MachineConfig()
+ )
+```
+
+**核心功能**:
+- 硬件初始化和连接
+- 移液操作控制
+- 运动控制
+- 错误处理和状态管理
+
+## 🎯 控制流程
+
+### 1. 设备初始化流程
+```
+用户代码 → LaiYu_Liquid.setup() → LaiYuLiquidBackend.setup()
+ ↓
+PipetteController 初始化 ← → XYZController 初始化
+ ↓ ↓
+SOPAPipetteDriver.connect() XYZStepperDriver.connect()
+ ↓ ↓
+串口连接 (/dev/cu.usbserial-3130)
+```
+
+### 2. 移液操作流程
+```
+用户调用 aspirate(volume) → LaiYuLiquidBackend.aspirate()
+ ↓
+检查设备状态 (连接、枪头、体积)
+ ↓
+PipetteController.aspirate(volume)
+ ↓
+SOPAPipetteDriver.aspirate_volume()
+ ↓
+串口命令发送到移液器硬件
+```
+
+### 3. 运动控制流程
+```
+用户调用 move_to(position) → LaiYuLiquidBackend.move_to()
+ ↓
+坐标转换和安全检查
+ ↓
+XYZController.move_to_work_coord()
+ ↓
+XYZStepperDriver.move_to()
+ ↓
+串口命令发送到步进电机
+```
+
+## 🔌 硬件通信
+
+### 串口配置
+- **端口**: `/dev/cu.usbserial-3130` (macOS)
+- **波特率**: 115200 (移液器), 可配置 (XYZ控制器)
+- **协议**: SOPA协议 (移液器), 自定义协议 (XYZ)
+
+### 通信协议
+1. **SOPA移液器协议**:
+ - 地址寻址: `address` 参数
+ - 命令格式: 二进制协议
+ - 响应处理: 异步等待
+
+2. **XYZ步进电机协议**:
+ - G代码风格命令
+ - 坐标系管理
+ - 实时状态反馈
+
+## 🛡️ 安全机制
+
+### 1. 连接检查
+```python
+def _check_hardware_ready(self):
+ """检查硬件是否就绪"""
+ if not self.is_connected:
+ raise DeviceError("设备未连接")
+ if not self.is_initialized:
+ raise DeviceError("设备未初始化")
+```
+
+### 2. 状态验证
+- 移液前检查枪头状态
+- 体积范围验证
+- 位置边界检查
+
+### 3. 错误处理
+- 硬件连接失败自动切换到模拟模式
+- 异常捕获和日志记录
+- 优雅的错误恢复
+
+## 📊 配置管理
+
+### LaiYuLiquidConfig
+```python
+@dataclass
+class LaiYuLiquidConfig:
+ port: str = "/dev/cu.usbserial-3130"
+ address: int = 1
+ baudrate: int = 115200
+ max_volume: float = 1000.0
+ min_volume: float = 0.1
+ # ... 其他配置参数
+```
+
+### 配置文件支持
+- **YAML**: `laiyu_liquid.yaml`
+- **JSON**: `laiyu_liquid.json`
+- **环境变量**: 动态配置覆盖
+
+## 🔄 异步操作
+
+所有硬件操作都是异步的,支持:
+- 并发操作
+- 非阻塞等待
+- 超时处理
+- 取消操作
+
+```python
+async def aspirate(self, volume: float) -> bool:
+ """异步吸液操作"""
+ try:
+ # 硬件操作
+ result = await self.pipette_controller.aspirate(volume)
+ # 状态更新
+ self._update_volume_state(volume)
+ return result
+ except Exception as e:
+ logger.error(f"吸液失败: {e}")
+ return False
+```
+
+## 🎮 实际使用示例
+
+```python
+# 1. 创建设备配置
+config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130",
+ address=1,
+ baudrate=115200
+)
+
+# 2. 初始化设备
+device = LaiYu_Liquid(config)
+await device.setup()
+
+# 3. 执行移液操作
+await device.pick_up_tip()
+await device.aspirate(100.0) # 吸取100μL
+await device.move_to((50, 50, 10)) # 移动到目标位置
+await device.dispense(100.0) # 分配100μL
+await device.drop_tip()
+
+# 4. 清理
+await device.stop()
+```
+
+## 🔍 调试和监控
+
+### 日志系统
+- 详细的操作日志
+- 错误追踪
+- 性能监控
+
+### 状态查询
+```python
+# 实时状态查询
+print(f"连接状态: {device.is_connected}")
+print(f"当前位置: {device.current_position}")
+print(f"当前体积: {device.current_volume}")
+print(f"枪头状态: {device.tip_attached}")
+```
+
+## 📈 扩展性
+
+### 1. 新设备支持
+- 继承抽象基类
+- 实现标准接口
+- 插件式架构
+
+### 2. 协议扩展
+- 新通信协议支持
+- 自定义命令集
+- 协议适配器
+
+### 3. 功能扩展
+- 新的移液模式
+- 高级运动控制
+- 智能优化算法
+
+## 🎯 总结
+
+UniLab 通过精心设计的分层架构,实现了对 LaiYu_Liquid 设备的完整控制:
+
+1. **高层抽象**: 提供简洁的API接口
+2. **中层控制**: 实现复杂的业务逻辑
+3. **底层驱动**: 处理硬件通信细节
+4. **安全可靠**: 完善的错误处理机制
+5. **易于扩展**: 模块化设计支持功能扩展
+
+这种架构使得用户可以专注于实验逻辑,而无需关心底层硬件控制的复杂性。
\ No newline at end of file
diff --git a/XYZ_集成功能说明.md b/XYZ_集成功能说明.md
new file mode 100644
index 00000000..301b1136
--- /dev/null
+++ b/XYZ_集成功能说明.md
@@ -0,0 +1,221 @@
+# XYZ步进电机与移液器集成功能说明
+
+## 概述
+
+本文档描述了XYZ步进电机控制器与SOPA移液器的集成功能,实现了移液器在Z轴方向的精确运动控制,特别是在吸头装载过程中的自动定位功能。
+
+## 新增功能
+
+### 1. XYZ控制器集成
+
+#### 初始化参数
+```python
+controller = PipetteController(
+ port="/dev/ttyUSB0", # 移液器串口
+ address=4, # 移液器Modbus地址
+ xyz_port="/dev/ttyUSB1" # XYZ控制器串口(可选)
+)
+```
+
+#### 连接管理
+- 自动检测并连接XYZ步进电机控制器
+- 支持独立的移液器操作(不依赖XYZ控制器)
+- 提供连接状态查询功能
+
+### 2. Z轴运动控制
+
+#### 相对运动功能
+```python
+# 向下移动10mm(用于吸头装载)
+success = controller.move_z_relative(-10.0, speed=2000, acceleration=500)
+
+# 向上移动5mm
+success = controller.move_z_relative(5.0, speed=1500)
+```
+
+#### 参数说明
+- `distance_mm`: 移动距离(毫米),负值向下,正值向上
+- `speed`: 运动速度(100-5000 rpm)
+- `acceleration`: 加速度(默认500)
+
+#### 步距转换
+- 转换比例: 1mm = 1638.4步
+- 支持精确的毫米到步数转换
+- 自动处理位置计算和验证
+
+### 3. 安全检查机制
+
+#### 运动限制
+- **移动距离限制**: 单次移动最大15mm
+- **速度限制**: 100-5000 rpm
+- **位置限制**: Z轴位置范围 -50000 到 50000步
+- **单次移动步数限制**: 最大20000步(约12mm)
+
+#### 安全检查功能
+```python
+def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
+ """执行XYZ轴运动前的安全检查"""
+ # 检查电机使能状态
+ # 检查错误状态
+ # 验证位置限制
+ # 验证移动距离
+```
+
+### 4. 增强的吸头装载功能
+
+#### 自动Z轴定位
+```python
+def pickup_tip(self) -> bool:
+ """装载吸头(包含Z轴自动定位)"""
+ # 检查当前吸头状态
+ # 执行Z轴下降运动(10mm)
+ # 执行移液器吸头装载
+ # 更新吸头状态
+```
+
+#### 工作流程
+1. 检查是否已有吸头装载
+2. 如果配置了XYZ控制器,执行Z轴下降10mm
+3. 执行移液器的吸头装载动作
+4. 更新吸头状态和统计信息
+
+### 5. 紧急停止功能
+
+#### 全系统停止
+```python
+def emergency_stop(self) -> bool:
+ """紧急停止所有运动"""
+ # 停止移液器运动
+ # 停止XYZ步进电机运动
+ # 记录停止状态
+```
+
+#### 使用场景
+- 检测到异常情况时立即停止
+- 用户手动中断操作
+- 系统故障保护
+
+### 6. 状态监控
+
+#### 设备状态查询
+```python
+status = controller.get_status()
+# 返回包含移液器和XYZ控制器状态的字典
+{
+ 'pipette': {
+ 'connected': True,
+ 'tip_status': 'tip_attached',
+ 'current_volume': 0.0,
+ # ...
+ },
+ 'xyz_controller': {
+ 'connected': True,
+ 'port': '/dev/ttyUSB1'
+ }
+}
+```
+
+## 使用示例
+
+### 基本使用流程
+
+```python
+from unilabos.devices.LaiYu_Liquid.controllers.pipette_controller import PipetteController
+
+# 1. 创建控制器实例
+controller = PipetteController(
+ port="/dev/ttyUSB0",
+ address=4,
+ xyz_port="/dev/ttyUSB1" # 可选
+)
+
+# 2. 连接设备
+if controller.connect():
+ print("设备连接成功")
+
+ # 3. 初始化
+ if controller.initialize():
+ print("设备初始化成功")
+
+ # 4. 装载吸头(自动Z轴定位)
+ if controller.pickup_tip():
+ print("吸头装载成功")
+
+ # 5. 执行液体操作
+ controller.aspirate(100.0) # 吸取100μL
+ controller.dispense(100.0) # 排出100μL
+
+ # 6. 弹出吸头
+ controller.eject_tip()
+
+ # 7. 断开连接
+ controller.disconnect()
+```
+
+### 手动Z轴控制
+
+```python
+# 精确的Z轴运动控制
+controller.move_z_relative(-5.0, speed=1000) # 下降5mm
+time.sleep(1) # 等待运动完成
+controller.move_z_relative(5.0, speed=1000) # 上升5mm
+```
+
+## 集成测试
+
+### 测试脚本
+使用 `test_xyz_pipette_integration.py` 脚本进行完整的集成测试:
+
+```bash
+python test_xyz_pipette_integration.py
+```
+
+### 测试项目
+1. **连接状态测试** - 验证设备连接
+2. **Z轴运动测试** - 验证运动控制
+3. **吸头装载测试** - 验证集成功能
+4. **安全检查测试** - 验证安全机制
+5. **紧急停止测试** - 验证停止功能
+6. **液体操作测试** - 验证基本功能
+
+## 配置要求
+
+### 硬件要求
+- SOPA移液器(支持Modbus RTU通信)
+- XYZ步进电机控制器(可选)
+- 串口连接线
+
+### 软件依赖
+- Python 3.7+
+- pymodbus库
+- pyserial库
+
+### 串口配置
+- 波特率: 9600
+- 数据位: 8
+- 停止位: 1
+- 校验位: None
+
+## 注意事项
+
+### 安全提醒
+1. **首次使用前必须进行安全检查**
+2. **确保Z轴运动范围内无障碍物**
+3. **紧急情况下立即使用紧急停止功能**
+4. **定期检查设备连接状态**
+
+### 故障排除
+1. **连接失败**: 检查串口配置和设备电源
+2. **运动异常**: 检查电机使能状态和位置限制
+3. **吸头装载失败**: 检查Z轴位置和吸头供应
+4. **通信错误**: 检查Modbus地址和通信参数
+
+## 更新记录
+
+- **2024-01**: 初始版本,实现基本集成功能
+- **2024-01**: 添加安全检查和紧急停止功能
+- **2024-01**: 完善错误处理和状态监控
+
+## 技术支持
+
+如有技术问题,请联系开发团队或查阅相关技术文档。
\ No newline at end of file
diff --git a/test/experiments/laiyu_liquid.json b/test/experiments/laiyu_liquid.json
new file mode 100644
index 00000000..4835460f
--- /dev/null
+++ b/test/experiments/laiyu_liquid.json
@@ -0,0 +1,148 @@
+{
+ "nodes": [
+ {
+ "id": "laiyu_liquid_station",
+ "name": "LaiYu液体处理工作站",
+ "children": [
+ "module_1_8tubes",
+ "module_2_96well_deep",
+ "module_3_beaker",
+ "module_4_96well_tips"
+ ],
+ "parent": null,
+ "type": "device",
+ "class": "laiyu_liquid",
+ "position": {
+ "x": 500,
+ "y": 200,
+ "z": 0
+ },
+ "config": {
+ "total_modules": 4,
+ "total_wells": 201,
+ "safety_margin": {
+ "x": 5.0,
+ "y": 5.0,
+ "z": 5.0
+ },
+ "protocol_type": ["LiquidHandlingProtocol", "PipettingProtocol", "TransferProtocol"]
+ },
+ "data": {
+ "status": "Ready",
+ "version": "1.0"
+ }
+ },
+ {
+ "id": "module_1_8tubes",
+ "name": "8管位置模块",
+ "children": [],
+ "parent": "laiyu_liquid_station",
+ "type": "container",
+ "class": "opentrons_24_tuberack_nest_1point5ml_snapcap",
+ "position": {
+ "x": 100,
+ "y": 100,
+ "z": 0
+ },
+ "config": {
+ "module_type": "tube_rack",
+ "wells_count": 8,
+ "well_diameter": 29.0,
+ "well_depth": 117.0,
+ "well_volume": 77000.0,
+ "well_shape": "circular",
+ "layout": "2x4"
+ },
+ "data": {
+ "max_volume": 77000.0,
+ "current_volume": 0.0,
+ "wells": ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
+ }
+ },
+ {
+ "id": "module_2_96well_deep",
+ "name": "96深孔板",
+ "children": [],
+ "parent": "laiyu_liquid_station",
+ "type": "plate",
+ "class": "nest_96_wellplate_2ml_deep",
+ "position": {
+ "x": 300,
+ "y": 100,
+ "z": 0
+ },
+ "config": {
+ "module_type": "96_well_deep_plate",
+ "wells_count": 96,
+ "well_diameter": 8.2,
+ "well_depth": 39.4,
+ "well_volume": 2080.0,
+ "well_shape": "circular",
+ "layout": "8x12"
+ },
+ "data": {
+ "max_volume": 2080.0,
+ "current_volume": 0.0,
+ "plate_type": "deep_well"
+ }
+ },
+ {
+ "id": "module_3_beaker",
+ "name": "敞口玻璃瓶",
+ "children": [],
+ "parent": "laiyu_liquid_station",
+ "type": "container",
+ "class": "container",
+ "position": {
+ "x": 500,
+ "y": 100,
+ "z": 0
+ },
+ "config": {
+ "module_type": "beaker_holder",
+ "wells_count": 1,
+ "well_diameter": 85.0,
+ "well_depth": 120.0,
+ "well_volume": 500000.0,
+ "well_shape": "circular",
+ "supported_containers": ["250ml", "500ml", "1000ml"]
+ },
+ "data": {
+ "max_volume": 500000.0,
+ "current_volume": 0.0,
+ "container_type": "beaker",
+ "wells": ["A1"]
+ }
+ },
+ {
+ "id": "module_4_96well_tips",
+ "name": "96吸头架",
+ "children": [],
+ "parent": "laiyu_liquid_station",
+ "type": "container",
+ "class": "tip_rack",
+ "position": {
+ "x": 700,
+ "y": 100,
+ "z": 0
+ },
+ "config": {
+ "module_type": "tip_rack",
+ "wells_count": 96,
+ "well_diameter": 8.2,
+ "well_depth": 60.0,
+ "well_volume": 6000.0,
+ "well_shape": "circular",
+ "layout": "8x12",
+ "tip_type": "standard"
+ },
+ "data": {
+ "max_volume": 6000.0,
+ "current_volume": 0.0,
+ "tip_capacity": "1000μL",
+ "tips_available": 96
+ }
+ }
+ ],
+ "links": []
+}
\ No newline at end of file
diff --git a/test/experiments/test_laiyu.json b/test/experiments/test_laiyu.json
new file mode 100644
index 00000000..fa439407
--- /dev/null
+++ b/test/experiments/test_laiyu.json
@@ -0,0 +1,394 @@
+{
+ "nodes": [
+ {
+ "id": "liquid_handler",
+ "name": "liquid_handler",
+ "parent": null,
+ "type": "device",
+ "class": "liquid_handler",
+ "position": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ },
+ "data": {},
+ "children": [
+ "deck"
+ ],
+ "config": {
+ "deck": {
+ "_resource_child_name": "deck",
+ "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck",
+ "name": "deck"
+ },
+ "backend": {
+ "type": "UniLiquidHandlerRvizBackend"
+
+ },
+ "simulator": true,
+ "total_height": 300
+ }
+ },
+ {
+ "id": "deck",
+ "name": "deck",
+ "sample_id": null,
+ "children": [
+ "tip_rack",
+ "plate_well",
+ "tube_rack",
+ "bottle_rack"
+ ],
+ "parent": "liquid_handler",
+ "type": "deck",
+ "class": "TransformXYZDeck",
+ "position": {
+ "x": 0,
+ "y": 0,
+ "z": 18
+ },
+ "config": {
+ "type": "TransformXYZDeck",
+ "size_x": 624.3,
+ "size_y": 565.2,
+ "size_z": 900,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ }
+ },
+ "data": {}
+ },
+ {
+ "id": "tip_rack",
+ "name": "tip_rack",
+ "sample_id": null,
+ "children": [
+ "tip_rack_A1"
+ ],
+ "parent": "deck",
+ "type": "tip_rack",
+ "class": "tiprack_box",
+ "position": {
+ "x": 150,
+ "y": 7,
+ "z": 103
+ },
+ "config": {
+ "type": "TipRack",
+ "size_x": 134,
+ "size_y": 96,
+ "size_z": 7.0,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ },
+ "category": "tip_rack",
+ "model": "tiprack_box",
+ "ordering": [
+ "A1"
+ ]
+ },
+ "data": {}
+ },
+
+
+
+
+ {
+ "id": "tip_rack_A1",
+ "name": "tip_rack_A1",
+ "sample_id": null,
+ "children": [],
+ "parent": "tip_rack",
+ "type": "container",
+ "class": "",
+ "position": {
+ "x": 11.12,
+ "y": 75,
+ "z": -91.54
+ },
+ "config": {
+ "type": "TipSpot",
+ "size_x": 9,
+ "size_y": 9,
+ "size_z": 95,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ },
+ "category": "tip_spot",
+ "model": null,
+ "prototype_tip": {
+ "type": "Tip",
+ "total_tip_length": 95,
+ "has_filter": false,
+ "maximal_volume": 1000.0,
+ "fitting_depth": 3.29
+ }
+ },
+ "data": {
+ "tip": null,
+ "tip_state": null,
+ "pending_tip": null
+ }
+ },
+
+
+ {
+ "id": "plate_well",
+ "name": "plate_well",
+ "sample_id": null,
+ "children": [
+ "plate_well_A1"
+ ],
+ "parent": "deck",
+ "type": "plate",
+ "class": "plate_96",
+ "position": {
+ "x": 161,
+ "y": 116,
+ "z": 48.5
+ },
+ "pose": {
+ "position_3d": {
+ "x": 161,
+ "y": 116,
+ "z": 48.5
+ },
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ }
+ },
+ "config": {
+ "type": "Plate",
+ "size_x": 127.76,
+ "size_y": 85.48,
+ "size_z": 45.5,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ },
+ "category": "plate",
+ "model": "plate_96",
+ "ordering": [
+ "A1"
+ ]
+ },
+ "data": {}
+ },
+
+
+
+
+
+ {
+ "id": "plate_well_A1",
+ "name": "plate_well_A1",
+ "sample_id": null,
+ "children": [],
+ "parent": "plate_well",
+ "type": "device",
+ "class": "",
+ "position": {
+ "x": 10.1,
+ "y": 70,
+ "z": 6.1
+ },
+ "config": {
+ "type": "Well",
+ "size_x": 8.2,
+ "size_y": 8.2,
+ "size_z": 38,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ },
+ "category": "well",
+ "model": null,
+ "max_volume": 2000,
+ "material_z_thickness": null,
+ "compute_volume_from_height": null,
+ "compute_height_from_volume": null,
+ "bottom_type": "unknown",
+ "cross_section_type": "rectangle"
+ },
+ "data": {
+ "liquids": [["water", 50.0]],
+ "pending_liquids": [["water", 50.0]],
+ "liquid_history": []
+ }
+ },
+
+
+ {
+ "id": "tube_rack",
+ "name": "tube_rack",
+ "sample_id": null,
+ "children": [
+ "tube_rack_A1"
+ ],
+ "parent": "deck",
+ "type": "container",
+ "class": "tube_container",
+ "position": {
+ "x": 0,
+ "y": 127,
+ "z": 0
+ },
+ "config": {
+ "type": "Plate",
+ "size_x": 151,
+ "size_y": 75,
+ "size_z": 75,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ },
+ "model": "tube_container",
+ "ordering": [
+ "A1"
+ ]
+ },
+ "data": {}
+ },
+
+
+ {
+ "id": "tube_rack_A1",
+ "name": "tube_rack_A1",
+ "sample_id": null,
+ "children": [],
+ "parent": "tube_rack",
+ "type": "device",
+ "class": "",
+ "position": {
+ "x": 6,
+ "y": 38,
+ "z": 10
+ },
+ "config": {
+ "type": "Well",
+ "size_x": 34,
+ "size_y": 34,
+ "size_z": 117,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ },
+ "category": "tube",
+ "model": null,
+ "max_volume": 2000,
+ "material_z_thickness": null,
+ "compute_volume_from_height": null,
+ "compute_height_from_volume": null,
+ "bottom_type": "unknown",
+ "cross_section_type": "rectangle"
+ },
+ "data": {
+ "liquids": [["water", 50.0]],
+ "pending_liquids": [["water", 50.0]],
+ "liquid_history": []
+ }
+ }
+
+
+ ,
+
+
+ {
+ "id": "bottle_rack",
+ "name": "bottle_rack",
+ "sample_id": null,
+ "children": [
+ "bottle_rack_A1"
+ ],
+ "parent": "deck",
+ "type": "container",
+ "class": "bottle_container",
+ "position": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ },
+ "config": {
+ "type": "Plate",
+ "size_x": 130,
+ "size_y": 117,
+ "size_z": 8,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ },
+ "category": "tube_rack",
+ "model": "bottle_container",
+ "ordering": [
+ "A1"
+ ]
+ },
+ "data": {}
+ },
+
+
+ {
+ "id": "bottle_rack_A1",
+ "name": "bottle_rack_A1",
+ "sample_id": null,
+ "children": [],
+ "parent": "bottle_rack",
+ "type": "device",
+ "class": "",
+ "position": {
+ "x": 25,
+ "y": 18.5,
+ "z": 8
+ },
+ "config": {
+ "type": "Well",
+ "size_x": 80,
+ "size_y": 80,
+ "size_z": 117,
+ "rotation": {
+ "x": 0,
+ "y": 0,
+ "z": 0,
+ "type": "Rotation"
+ },
+ "category": "tube",
+ "model": null,
+ "max_volume": 2000,
+ "material_z_thickness": null,
+ "compute_volume_from_height": null,
+ "compute_height_from_volume": null,
+ "bottom_type": "unknown",
+ "cross_section_type": "rectangle"
+ },
+ "data": {
+ "liquids": [["water", 50.0]],
+ "pending_liquids": [["water", 50.0]],
+ "liquid_history": []
+ }
+ }
+
+
+ ],
+ "links": []
+}
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/macro_device.xacro b/unilabos/device_mesh/devices/liquid_transform_xyz/macro_device.xacro
new file mode 100644
index 00000000..c1ca35f2
--- /dev/null
+++ b/unilabos/device_mesh/devices/liquid_transform_xyz/macro_device.xacro
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/base_link.STL b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/base_link.STL
new file mode 100644
index 00000000..902bd361
Binary files /dev/null and b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/base_link.STL differ
diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/x_link.STL b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/x_link.STL
new file mode 100644
index 00000000..e5c43cc6
Binary files /dev/null and b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/x_link.STL differ
diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/y_link.STL b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/y_link.STL
new file mode 100644
index 00000000..e924b8c2
Binary files /dev/null and b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/y_link.STL differ
diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/z_link.STL b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/z_link.STL
new file mode 100644
index 00000000..6a304f9f
Binary files /dev/null and b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/z_link.STL differ
diff --git a/unilabos/devices/laiyu_liquid/__init__.py b/unilabos/devices/laiyu_liquid/__init__.py
new file mode 100644
index 00000000..89352521
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/__init__.py
@@ -0,0 +1,307 @@
+"""
+LaiYu_Liquid 液体处理工作站集成模块
+
+该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括:
+- 硬件后端和抽象接口
+- 资源定义和管理
+- 协议执行和液体传输
+- 工作台配置和布局
+
+主要组件:
+- LaiYuLiquidBackend: 硬件后端实现
+- LaiYuLiquid: 液体处理器抽象接口
+- 各种资源类:枪头架、板、容器等
+- 便捷创建函数和配置管理
+
+使用示例:
+ from unilabos.devices.laiyu_liquid import (
+ LaiYuLiquid,
+ LaiYuLiquidBackend,
+ create_standard_deck,
+ create_tip_rack_1000ul
+ )
+
+ # 创建后端和液体处理器
+ backend = LaiYuLiquidBackend()
+ lh = LaiYuLiquid(backend=backend)
+
+ # 创建工作台
+ deck = create_standard_deck()
+ lh.deck = deck
+
+ # 设置和运行
+ await lh.setup()
+"""
+
+# 版本信息
+__version__ = "1.0.0"
+__author__ = "LaiYu_Liquid Integration Team"
+__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块"
+
+# 驱动程序导入
+from .drivers import (
+ XYZStepperController,
+ SOPAPipette,
+ MotorAxis,
+ MotorStatus,
+ SOPAConfig,
+ SOPAStatusCode,
+ StepperMotorDriver
+)
+
+# 控制器导入
+from .controllers import (
+ XYZController,
+ PipetteController,
+)
+
+# 后端导入
+from .backend.rviz_backend import (
+ LiquidHandlerRvizBackend,
+)
+
+# 资源类和创建函数导入
+from .core.laiyu_liquid_res import (
+ LaiYuLiquidDeck,
+ LaiYuLiquidContainer,
+ LaiYuLiquidTipRack
+)
+
+# 主设备类和配置
+from .core.laiyu_liquid_main import (
+ LaiYuLiquid,
+ LaiYuLiquidConfig,
+ LaiYuLiquidDeck,
+ LaiYuLiquidContainer,
+ LaiYuLiquidTipRack,
+ create_quick_setup
+)
+
+# 后端创建函数导入
+from .backend import (
+ LaiYuLiquidBackend,
+ create_laiyu_backend,
+)
+
+# 导出所有公共接口
+__all__ = [
+ # 版本信息
+ "__version__",
+ "__author__",
+ "__description__",
+
+ # 驱动程序
+ "SOPAPipette",
+ "SOPAConfig",
+ "StepperMotorDriver",
+ "XYZStepperController",
+
+ # 控制器
+ "PipetteController",
+ "XYZController",
+
+ # 后端
+ "LiquidHandlerRvizBackend",
+
+ # 资源创建函数
+ "create_tip_rack_1000ul",
+ "create_tip_rack_200ul",
+ "create_96_well_plate",
+ "create_deep_well_plate",
+ "create_8_tube_rack",
+ "create_standard_deck",
+ "create_waste_container",
+ "create_wash_container",
+ "create_reagent_container",
+ "load_deck_config",
+
+ # 后端创建函数
+ "create_laiyu_backend",
+
+ # 主要类
+ "LaiYuLiquid",
+ "LaiYuLiquidConfig",
+ "LaiYuLiquidBackend",
+ "LaiYuLiquidDeck",
+
+ # 工具函数
+ "get_version",
+ "get_supported_resources",
+ "create_quick_setup",
+ "validate_installation",
+ "print_module_info",
+ "setup_logging",
+]
+
+# 别名定义,为了向后兼容
+LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名
+LaiYuLiquidController = XYZController # 控制器别名
+LaiYuLiquidDriver = XYZStepperController # 驱动器别名
+
+# 模块级别的便捷函数
+
+def get_version() -> str:
+ """
+ 获取模块版本
+
+ Returns:
+ str: 版本号
+ """
+ return __version__
+
+
+def get_supported_resources() -> dict:
+ """
+ 获取支持的资源类型
+
+ Returns:
+ dict: 支持的资源类型字典
+ """
+ return {
+ "tip_racks": {
+ "LaiYuLiquidTipRack": LaiYuLiquidTipRack,
+ },
+ "containers": {
+ "LaiYuLiquidContainer": LaiYuLiquidContainer,
+ },
+ "decks": {
+ "LaiYuLiquidDeck": LaiYuLiquidDeck,
+ },
+ "devices": {
+ "LaiYuLiquid": LaiYuLiquid,
+ }
+ }
+
+
+def create_quick_setup() -> tuple:
+ """
+ 快速创建基本设置
+
+ Returns:
+ tuple: (backend, controllers, resources) 的元组
+ """
+ # 创建后端
+ backend = LiquidHandlerRvizBackend()
+
+ # 创建控制器(使用默认端口进行演示)
+ pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4)
+ xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False)
+
+ # 创建测试资源
+ tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
+ tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
+ well_plate = create_96_well_plate("96_well_plate")
+
+ controllers = {
+ 'pipette': pipette_controller,
+ 'xyz': xyz_controller
+ }
+
+ resources = {
+ 'tip_rack_1000': tip_rack_1000,
+ 'tip_rack_200': tip_rack_200,
+ 'well_plate': well_plate
+ }
+
+ return backend, controllers, resources
+
+
+def validate_installation() -> bool:
+ """
+ 验证模块安装是否正确
+
+ Returns:
+ bool: 安装是否正确
+ """
+ try:
+ # 检查核心类是否可以导入
+ from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig
+ from .backend import LaiYuLiquidBackend
+ from .controllers import XYZController, PipetteController
+ from .drivers import XYZStepperController, SOPAPipette
+
+ # 尝试创建基本对象
+ config = LaiYuLiquidConfig()
+ backend = create_laiyu_backend("validation_test")
+
+ print("模块安装验证成功")
+ return True
+
+ except Exception as e:
+ print(f"模块安装验证失败: {e}")
+ return False
+
+
+def print_module_info():
+ """打印模块信息"""
+ print(f"LaiYu_Liquid 集成模块")
+ print(f"版本: {__version__}")
+ print(f"作者: {__author__}")
+ print(f"描述: {__description__}")
+ print(f"")
+ print(f"支持的资源类型:")
+
+ resources = get_supported_resources()
+ for category, types in resources.items():
+ print(f" {category}:")
+ for type_name, type_class in types.items():
+ print(f" - {type_name}: {type_class.__name__}")
+
+ print(f"")
+ print(f"主要功能:")
+ print(f" - 硬件集成: LaiYuLiquidBackend")
+ print(f" - 抽象接口: LaiYuLiquid")
+ print(f" - 资源管理: 各种资源类和创建函数")
+ print(f" - 协议执行: transfer_liquid 和相关函数")
+ print(f" - 配置管理: deck.json 和加载函数")
+
+
+# 模块初始化时的检查
+def _check_dependencies():
+ """检查依赖项"""
+ try:
+ import pylabrobot
+ import asyncio
+ import json
+ import logging
+ return True
+ except ImportError as e:
+ import logging
+ logging.warning(f"缺少依赖项 {e}")
+ return False
+
+
+# 执行依赖检查
+_dependencies_ok = _check_dependencies()
+
+if not _dependencies_ok:
+ import logging
+ logging.warning("某些依赖项缺失,模块功能可能受限")
+
+
+# 模块级别的日志配置
+import logging
+
+def setup_logging(level: str = "INFO"):
+ """
+ 设置模块日志
+
+ Args:
+ level: 日志级别 (DEBUG, INFO, WARNING, ERROR)
+ """
+ logger = logging.getLogger("LaiYu_Liquid")
+ logger.setLevel(getattr(logging, level.upper()))
+
+ if not logger.handlers:
+ handler = logging.StreamHandler()
+ formatter = logging.Formatter(
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+ )
+ handler.setFormatter(formatter)
+ logger.addHandler(handler)
+
+ return logger
+
+
+# 默认日志设置
+_logger = setup_logging()
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/backend/__init__.py b/unilabos/devices/laiyu_liquid/backend/__init__.py
new file mode 100644
index 00000000..4bf29392
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/backend/__init__.py
@@ -0,0 +1,9 @@
+"""
+LaiYu液体处理设备后端模块
+
+提供设备后端接口和实现
+"""
+
+from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
+
+__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/backend/laiyu_backend.py b/unilabos/devices/laiyu_liquid/backend/laiyu_backend.py
new file mode 100644
index 00000000..5e8041c0
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/backend/laiyu_backend.py
@@ -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)
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/backend/rviz_backend.py b/unilabos/devices/laiyu_liquid/backend/rviz_backend.py
new file mode 100644
index 00000000..44e7be46
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/backend/rviz_backend.py
@@ -0,0 +1,209 @@
+
+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
+
+
+class LiquidHandlerRvizBackend(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):
+ """Initialize a chatter box backend."""
+ super().__init__()
+ self._num_channels = num_channels
+# rclpy.init()
+ if not rclpy.ok():
+ rclpy.init()
+ self.joint_state_publisher = None
+
+ async def setup(self):
+ self.joint_state_publisher = JointStatePublisher()
+ await super().setup()
+ async def stop(self):
+ pass
+
+ def serialize(self) -> dict:
+ return {**super().serialize(), "num_channels": self.num_channels}
+
+ @property
+ def num_channels(self) -> int:
+ return self._num_channels
+
+ async def assigned_resource_callback(self, resource: Resource):
+ pass
+
+ async def unassigned_resource_callback(self, name: str):
+ pass
+
+ async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
+
+ 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:]:<{LiquidHandlerRvizBackend._resource_length}} "
+ f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
+ f"{op.tip.__class__.__name__:<{LiquidHandlerRvizBackend._tip_type_length}} "
+ f"{op.tip.maximal_volume:<{LiquidHandlerRvizBackend._max_volume_length}} "
+ f"{op.tip.fitting_depth:<{LiquidHandlerRvizBackend._fitting_depth_length}} "
+ f"{op.tip.total_tip_length:<{LiquidHandlerRvizBackend._tip_length_length}} "
+ # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
+ f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerRvizBackend._filter_length}}"
+ )
+ coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
+ x = coordinate.x
+ y = coordinate.y
+ z = coordinate.z + 70
+ self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick")
+ # goback()
+
+
+
+
+ async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
+
+ coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
+ x = coordinate.x
+ y = coordinate.y
+ z = coordinate.z + 70
+ self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash")
+ # goback()
+
+ async def aspirate(
+ self,
+ ops: List[SingleChannelAspiration],
+ use_channels: List[int],
+ **backend_kwargs,
+ ):
+ # 执行吸液操作
+ pass
+
+ 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:<{LiquidHandlerRvizBackend._vol_length}} "
+ f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
+ f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
+ f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
+ f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
+ f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._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}"
+ coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
+ x = coordinate.x
+ y = coordinate.y
+ z = coordinate.z + 70
+ self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
+
+
+ async def dispense(
+ self,
+ ops: List[SingleChannelDispense],
+ use_channels: List[int],
+ **backend_kwargs,
+ ):
+
+ 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:<{LiquidHandlerRvizBackend._vol_length}} "
+ f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
+ f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
+ f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
+ f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
+ f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._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:<{LiquidHandlerRvizBackend._kwargs_length}}"
+ coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
+ x = coordinate.x
+ y = coordinate.y
+ z = coordinate.z + 70
+ self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
+
+ async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
+ pass
+
+ async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
+ pass
+
+ async def aspirate96(
+ self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
+ ):
+ pass
+
+ async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
+ pass
+
+ async def pick_up_resource(self, pickup: ResourcePickup):
+ # 执行资源拾取操作
+ pass
+
+ async def move_picked_up_resource(self, move: ResourceMove):
+ # 执行资源移动操作
+ pass
+
+ async def drop_resource(self, drop: ResourceDrop):
+ # 执行资源放置操作
+ pass
+
+ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
+ return True
+
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/config/deckconfig.json b/unilabos/devices/laiyu_liquid/config/deckconfig.json
new file mode 100644
index 00000000..ddda7e0f
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/config/deckconfig.json
@@ -0,0 +1,2620 @@
+{
+ "name": "LaiYu_Liquid_Deck",
+ "size_x": 340.0,
+ "size_y": 250.0,
+ "size_z": 160.0,
+ "coordinate_system": {
+ "origin": "top_left",
+ "x_axis": "right",
+ "y_axis": "down",
+ "z_axis": "up",
+ "units": "mm"
+ },
+ "children": [
+ {
+ "id": "module_1_8tubes",
+ "name": "8管位置模块",
+ "type": "tube_rack",
+ "position": {
+ "x": 0.0,
+ "y": 0.0,
+ "z": 0.0
+ },
+ "size": {
+ "x": 151.0,
+ "y": 75.0,
+ "z": 75.0
+ },
+ "wells": [
+ {
+ "id": "A1",
+ "position": {
+ "x": 23.0,
+ "y": 20.0,
+ "z": 0.0
+ },
+ "diameter": 29.0,
+ "depth": 117.0,
+ "volume": 77000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A2",
+ "position": {
+ "x": 58.0,
+ "y": 20.0,
+ "z": 0.0
+ },
+ "diameter": 29.0,
+ "depth": 117.0,
+ "volume": 77000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A3",
+ "position": {
+ "x": 93.0,
+ "y": 20.0,
+ "z": 0.0
+ },
+ "diameter": 29.0,
+ "depth": 117.0,
+ "volume": 77000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A4",
+ "position": {
+ "x": 128.0,
+ "y": 20.0,
+ "z": 0.0
+ },
+ "diameter": 29.0,
+ "depth": 117.0,
+ "volume": 77000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B1",
+ "position": {
+ "x": 23.0,
+ "y": 55.0,
+ "z": 0.0
+ },
+ "diameter": 29.0,
+ "depth": 117.0,
+ "volume": 77000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B2",
+ "position": {
+ "x": 58.0,
+ "y": 55.0,
+ "z": 0.0
+ },
+ "diameter": 29.0,
+ "depth": 117.0,
+ "volume": 77000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B3",
+ "position": {
+ "x": 93.0,
+ "y": 55.0,
+ "z": 0.0
+ },
+ "diameter": 29.0,
+ "depth": 117.0,
+ "volume": 77000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B4",
+ "position": {
+ "x": 128.0,
+ "y": 55.0,
+ "z": 0.0
+ },
+ "diameter": 29.0,
+ "depth": 117.0,
+ "volume": 77000.0,
+ "shape": "circular"
+ }
+ ],
+ "well_spacing": {
+ "x": 35.0,
+ "y": 35.0
+ },
+ "grid": {
+ "rows": 2,
+ "columns": 4,
+ "row_labels": ["A", "B"],
+ "column_labels": ["1", "2", "3", "4"]
+ },
+ "metadata": {
+ "description": "8个试管位置,2x4排列",
+ "max_volume_ul": 77000,
+ "well_count": 8,
+ "tube_type": "50ml_falcon"
+ }
+ },
+ {
+ "id": "module_2_96well_deep",
+ "name": "96深孔板",
+ "type": "96_well_plate",
+ "position": {
+ "x": 175.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "size": {
+ "x": 127.1,
+ "y": 85.6,
+ "z": 45.5
+ },
+ "wells": [
+ {
+ "id": "A01",
+ "position": {
+ "x": 175.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A02",
+ "position": {
+ "x": 184.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A03",
+ "position": {
+ "x": 193.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A04",
+ "position": {
+ "x": 202.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A05",
+ "position": {
+ "x": 211.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A06",
+ "position": {
+ "x": 220.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A07",
+ "position": {
+ "x": 229.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A08",
+ "position": {
+ "x": 238.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A09",
+ "position": {
+ "x": 247.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A10",
+ "position": {
+ "x": 256.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A11",
+ "position": {
+ "x": 265.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A12",
+ "position": {
+ "x": 274.0,
+ "y": 11.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B01",
+ "position": {
+ "x": 175.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B02",
+ "position": {
+ "x": 184.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B03",
+ "position": {
+ "x": 193.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B04",
+ "position": {
+ "x": 202.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B05",
+ "position": {
+ "x": 211.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B06",
+ "position": {
+ "x": 220.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B07",
+ "position": {
+ "x": 229.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B08",
+ "position": {
+ "x": 238.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B09",
+ "position": {
+ "x": 247.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B10",
+ "position": {
+ "x": 256.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B11",
+ "position": {
+ "x": 265.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B12",
+ "position": {
+ "x": 274.0,
+ "y": 20.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C01",
+ "position": {
+ "x": 175.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C02",
+ "position": {
+ "x": 184.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C03",
+ "position": {
+ "x": 193.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C04",
+ "position": {
+ "x": 202.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C05",
+ "position": {
+ "x": 211.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C06",
+ "position": {
+ "x": 220.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C07",
+ "position": {
+ "x": 229.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C08",
+ "position": {
+ "x": 238.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C09",
+ "position": {
+ "x": 247.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C10",
+ "position": {
+ "x": 256.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C11",
+ "position": {
+ "x": 265.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C12",
+ "position": {
+ "x": 274.0,
+ "y": 29.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D01",
+ "position": {
+ "x": 175.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D02",
+ "position": {
+ "x": 184.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D03",
+ "position": {
+ "x": 193.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D04",
+ "position": {
+ "x": 202.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D05",
+ "position": {
+ "x": 211.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D06",
+ "position": {
+ "x": 220.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D07",
+ "position": {
+ "x": 229.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D08",
+ "position": {
+ "x": 238.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D09",
+ "position": {
+ "x": 247.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D10",
+ "position": {
+ "x": 256.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D11",
+ "position": {
+ "x": 265.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D12",
+ "position": {
+ "x": 274.0,
+ "y": 38.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E01",
+ "position": {
+ "x": 175.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E02",
+ "position": {
+ "x": 184.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E03",
+ "position": {
+ "x": 193.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E04",
+ "position": {
+ "x": 202.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E05",
+ "position": {
+ "x": 211.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E06",
+ "position": {
+ "x": 220.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E07",
+ "position": {
+ "x": 229.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E08",
+ "position": {
+ "x": 238.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E09",
+ "position": {
+ "x": 247.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E10",
+ "position": {
+ "x": 256.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E11",
+ "position": {
+ "x": 265.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E12",
+ "position": {
+ "x": 274.0,
+ "y": 47.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F01",
+ "position": {
+ "x": 175.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F02",
+ "position": {
+ "x": 184.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F03",
+ "position": {
+ "x": 193.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F04",
+ "position": {
+ "x": 202.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F05",
+ "position": {
+ "x": 211.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F06",
+ "position": {
+ "x": 220.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F07",
+ "position": {
+ "x": 229.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F08",
+ "position": {
+ "x": 238.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F09",
+ "position": {
+ "x": 247.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F10",
+ "position": {
+ "x": 256.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F11",
+ "position": {
+ "x": 265.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F12",
+ "position": {
+ "x": 274.0,
+ "y": 56.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G01",
+ "position": {
+ "x": 175.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G02",
+ "position": {
+ "x": 184.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G03",
+ "position": {
+ "x": 193.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G04",
+ "position": {
+ "x": 202.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G05",
+ "position": {
+ "x": 211.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G06",
+ "position": {
+ "x": 220.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G07",
+ "position": {
+ "x": 229.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G08",
+ "position": {
+ "x": 238.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G09",
+ "position": {
+ "x": 247.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G10",
+ "position": {
+ "x": 256.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G11",
+ "position": {
+ "x": 265.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G12",
+ "position": {
+ "x": 274.0,
+ "y": 65.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H01",
+ "position": {
+ "x": 175.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H02",
+ "position": {
+ "x": 184.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H03",
+ "position": {
+ "x": 193.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H04",
+ "position": {
+ "x": 202.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H05",
+ "position": {
+ "x": 211.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H06",
+ "position": {
+ "x": 220.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H07",
+ "position": {
+ "x": 229.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H08",
+ "position": {
+ "x": 238.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H09",
+ "position": {
+ "x": 247.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H10",
+ "position": {
+ "x": 256.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H11",
+ "position": {
+ "x": 265.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H12",
+ "position": {
+ "x": 274.0,
+ "y": 74.0,
+ "z": 48.5
+ },
+ "diameter": 8.2,
+ "depth": 39.4,
+ "volume": 2080.0,
+ "shape": "circular"
+ }
+ ],
+ "well_spacing": {
+ "x": 9.0,
+ "y": 9.0
+ },
+ "grid": {
+ "rows": 8,
+ "columns": 12,
+ "row_labels": ["A", "B", "C", "D", "E", "F", "G", "H"],
+ "column_labels": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]
+ },
+ "metadata": {
+ "description": "深孔96孔板",
+ "max_volume_ul": 2080,
+ "well_count": 96,
+ "plate_type": "deep_well_plate"
+ }
+ },
+ {
+ "id": "module_3_beaker",
+ "name": "敞口玻璃瓶",
+ "type": "beaker_holder",
+ "position": {
+ "x": 65.0,
+ "y": 143.5,
+ "z": 0.0
+ },
+ "size": {
+ "x": 130.0,
+ "y": 117.0,
+ "z": 110.0
+ },
+ "wells": [
+ {
+ "id": "A1",
+ "position": {
+ "x": 65.0,
+ "y": 143.5,
+ "z": 0.0
+ },
+ "diameter": 80.0,
+ "depth": 145.0,
+ "volume": 500000.0,
+ "shape": "circular",
+ "container_type": "beaker"
+ }
+ ],
+ "supported_containers": [
+ {
+ "type": "beaker_250ml",
+ "diameter": 70.0,
+ "height": 95.0,
+ "volume": 250000.0
+ },
+ {
+ "type": "beaker_500ml",
+ "diameter": 85.0,
+ "height": 115.0,
+ "volume": 500000.0
+ },
+ {
+ "type": "beaker_1000ml",
+ "diameter": 105.0,
+ "height": 145.0,
+ "volume": 1000000.0
+ }
+ ],
+ "metadata": {
+ "description": "敞口玻璃瓶固定座,支持250ml-1000ml烧杯",
+ "max_beaker_diameter": 80.0,
+ "max_beaker_height": 145.0,
+ "well_count": 1,
+ "access_from_top": true
+ }
+ },
+ {
+ "id": "module_4_96well_tips",
+ "name": "96枪头盒",
+ "type": "96_tip_rack",
+ "position": {
+ "x": 165.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "size": {
+ "x": 134.0,
+ "y": 96.0,
+ "z": 7.0
+ },
+ "wells": [
+ {
+ "id": "A01",
+ "position": {
+ "x": 165.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A02",
+ "position": {
+ "x": 174.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A03",
+ "position": {
+ "x": 183.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A04",
+ "position": {
+ "x": 192.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A05",
+ "position": {
+ "x": 201.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A06",
+ "position": {
+ "x": 210.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A07",
+ "position": {
+ "x": 219.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A08",
+ "position": {
+ "x": 228.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A09",
+ "position": {
+ "x": 237.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A10",
+ "position": {
+ "x": 246.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A11",
+ "position": {
+ "x": 255.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "A12",
+ "position": {
+ "x": 264.62,
+ "y": 115.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B01",
+ "position": {
+ "x": 165.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B02",
+ "position": {
+ "x": 174.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B03",
+ "position": {
+ "x": 183.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B04",
+ "position": {
+ "x": 192.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B05",
+ "position": {
+ "x": 201.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B06",
+ "position": {
+ "x": 210.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B07",
+ "position": {
+ "x": 219.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B08",
+ "position": {
+ "x": 228.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B09",
+ "position": {
+ "x": 237.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B10",
+ "position": {
+ "x": 246.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B11",
+ "position": {
+ "x": 255.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "B12",
+ "position": {
+ "x": 264.62,
+ "y": 124.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C01",
+ "position": {
+ "x": 165.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C02",
+ "position": {
+ "x": 174.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C03",
+ "position": {
+ "x": 183.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C04",
+ "position": {
+ "x": 192.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C05",
+ "position": {
+ "x": 201.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C06",
+ "position": {
+ "x": 210.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C07",
+ "position": {
+ "x": 219.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C08",
+ "position": {
+ "x": 228.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C09",
+ "position": {
+ "x": 237.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C10",
+ "position": {
+ "x": 246.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C11",
+ "position": {
+ "x": 255.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "C12",
+ "position": {
+ "x": 264.62,
+ "y": 133.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D01",
+ "position": {
+ "x": 165.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D02",
+ "position": {
+ "x": 174.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D03",
+ "position": {
+ "x": 183.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D04",
+ "position": {
+ "x": 192.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D05",
+ "position": {
+ "x": 201.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D06",
+ "position": {
+ "x": 210.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D07",
+ "position": {
+ "x": 219.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D08",
+ "position": {
+ "x": 228.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D09",
+ "position": {
+ "x": 237.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D10",
+ "position": {
+ "x": 246.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D11",
+ "position": {
+ "x": 255.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "D12",
+ "position": {
+ "x": 264.62,
+ "y": 142.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E01",
+ "position": {
+ "x": 165.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E02",
+ "position": {
+ "x": 174.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E03",
+ "position": {
+ "x": 183.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E04",
+ "position": {
+ "x": 192.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E05",
+ "position": {
+ "x": 201.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E06",
+ "position": {
+ "x": 210.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E07",
+ "position": {
+ "x": 219.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E08",
+ "position": {
+ "x": 228.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E09",
+ "position": {
+ "x": 237.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E10",
+ "position": {
+ "x": 246.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E11",
+ "position": {
+ "x": 255.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "E12",
+ "position": {
+ "x": 264.62,
+ "y": 151.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F01",
+ "position": {
+ "x": 165.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F02",
+ "position": {
+ "x": 174.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F03",
+ "position": {
+ "x": 183.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F04",
+ "position": {
+ "x": 192.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F05",
+ "position": {
+ "x": 201.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F06",
+ "position": {
+ "x": 210.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F07",
+ "position": {
+ "x": 219.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F08",
+ "position": {
+ "x": 228.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F09",
+ "position": {
+ "x": 237.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F10",
+ "position": {
+ "x": 246.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F11",
+ "position": {
+ "x": 255.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "F12",
+ "position": {
+ "x": 264.62,
+ "y": 160.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G01",
+ "position": {
+ "x": 165.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G02",
+ "position": {
+ "x": 174.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G03",
+ "position": {
+ "x": 183.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G04",
+ "position": {
+ "x": 192.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G05",
+ "position": {
+ "x": 201.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G06",
+ "position": {
+ "x": 210.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G07",
+ "position": {
+ "x": 219.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G08",
+ "position": {
+ "x": 228.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G09",
+ "position": {
+ "x": 237.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G10",
+ "position": {
+ "x": 246.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G11",
+ "position": {
+ "x": 255.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "G12",
+ "position": {
+ "x": 264.62,
+ "y": 169.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H01",
+ "position": {
+ "x": 165.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H02",
+ "position": {
+ "x": 174.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H03",
+ "position": {
+ "x": 183.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H04",
+ "position": {
+ "x": 192.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H05",
+ "position": {
+ "x": 201.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H06",
+ "position": {
+ "x": 210.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H07",
+ "position": {
+ "x": 219.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H08",
+ "position": {
+ "x": 228.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H09",
+ "position": {
+ "x": 237.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H10",
+ "position": {
+ "x": 246.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H11",
+ "position": {
+ "x": 255.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ },
+ {
+ "id": "H12",
+ "position": {
+ "x": 264.62,
+ "y": 178.5,
+ "z": 103.0
+ },
+ "diameter": 9.0,
+ "depth": 95.0,
+ "volume": 6000.0,
+ "shape": "circular"
+ }
+ ],
+ "well_spacing": {
+ "x": 9.0,
+ "y": 9.0
+ },
+ "grid": {
+ "rows": 8,
+ "columns": 12,
+ "row_labels": ["A", "B", "C", "D", "E", "F", "G", "H"],
+ "column_labels": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]
+ },
+ "metadata": {
+ "description": "标准96孔枪头盒",
+ "max_volume_ul": 6000,
+ "well_count": 96,
+ "plate_type": "tip_rack"
+ }
+ }
+ ],
+ "deck_metadata": {
+ "total_modules": 4,
+ "total_wells": 201,
+ "deck_area": {
+ "used_x": 299.62,
+ "used_y": 260.5,
+ "used_z": 103.0,
+ "efficiency_x": 88.1,
+ "efficiency_y": 104.2,
+ "efficiency_z": 64.4
+ },
+ "safety_margins": {
+ "x_min": 10.0,
+ "x_max": 10.0,
+ "y_min": 10.0,
+ "y_max": 10.0,
+ "z_clearance": 20.0
+ },
+ "calibration_points": [
+ {
+ "id": "origin",
+ "position": {"x": 0.0, "y": 0.0, "z": 0.0},
+ "description": "工作台左上角原点"
+ },
+ {
+ "id": "module_1_ref",
+ "position": {"x": 23.0, "y": 20.0, "z": 0.0},
+ "description": "模块1试管架基准孔A1"
+ },
+ {
+ "id": "module_2_ref",
+ "position": {"x": 175.0, "y": 11.0, "z": 48.5},
+ "description": "模块2深孔板基准孔A01"
+ },
+ {
+ "id": "module_3_ref",
+ "position": {"x": 65.0, "y": 143.5, "z": 0.0},
+ "description": "模块3敞口玻璃瓶中心"
+ },
+ {
+ "id": "module_4_ref",
+ "position": {"x": 165.62, "y": 115.5, "z": 103.0},
+ "description": "模块4枪头盒基准孔A01"
+ }
+ ],
+ "version": "2.0",
+ "created_by": "Doraemon Team",
+ "last_updated": "2025-09-29"
+ }
+}
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/config/deckconfig.md b/unilabos/devices/laiyu_liquid/config/deckconfig.md
new file mode 100644
index 00000000..7359e625
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/config/deckconfig.md
@@ -0,0 +1,14 @@
+ goto 171 178 57 H1
+ goto 171 117 57 A1
+ goto 172 178 130
+ goto 173 179 133
+ goto 173 180 133
+goto 173 180 138
+goto 173 180 125 (+10mm,在空的上面边缘)
+goto 173 180 130 取不到
+goto 173 180 133 取不到
+goto 173 180 135
+goto 173 180 137 取到了!!!!
+goto 173 180 131 弹出枪头 H1
+
+goto 173 117 137 A1 (+10mm,可以取到新枪头了!!!!)
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/controllers/__init__.py b/unilabos/devices/laiyu_liquid/controllers/__init__.py
new file mode 100644
index 00000000..d50b1eca
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/controllers/__init__.py
@@ -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 高级控制器集合"
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/controllers/pipette_controller.py b/unilabos/devices/laiyu_liquid/controllers/pipette_controller.py
new file mode 100644
index 00000000..6c314a3a
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/controllers/pipette_controller.py
@@ -0,0 +1,1073 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+移液控制器模块
+封装SOPA移液器的高级控制功能
+"""
+
+# 添加项目根目录到Python路径以解决模块导入问题
+import sys
+import os
+
+# 无论如何都添加项目根目录到路径
+current_file = os.path.abspath(__file__)
+# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py
+# 向上5级到 .../Uni-Lab-OS
+project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
+# 强制添加项目根目录到sys.path的开头
+sys.path.insert(0, project_root)
+
+import time
+import logging
+from typing import Optional, List, Dict, Tuple
+from dataclasses import dataclass
+from enum import Enum
+
+from unilabos.devices.laiyu_liquid.drivers.sopa_pipette_driver import (
+ SOPAPipette,
+ SOPAConfig,
+ SOPAStatusCode,
+ DetectionMode,
+ create_sopa_pipette,
+)
+from unilabos.devices.laiyu_liquid.drivers.xyz_stepper_driver import (
+ XYZStepperController,
+ MotorAxis,
+ MotorStatus,
+ ModbusException
+)
+
+logger = logging.getLogger(__name__)
+
+
+class TipStatus(Enum):
+ """枪头状态"""
+ NO_TIP = "no_tip"
+ TIP_ATTACHED = "tip_attached"
+ TIP_USED = "tip_used"
+
+
+class LiquidClass(Enum):
+ """液体类型"""
+ WATER = "water"
+ SERUM = "serum"
+ VISCOUS = "viscous"
+ VOLATILE = "volatile"
+ CUSTOM = "custom"
+
+
+@dataclass
+class LiquidParameters:
+ """液体处理参数"""
+ aspirate_speed: int = 500 # 吸液速度
+ dispense_speed: int = 800 # 排液速度
+ air_gap: float = 10.0 # 空气间隙
+ blow_out: float = 5.0 # 吹出量
+ pre_wet: bool = False # 预润湿
+ mix_cycles: int = 0 # 混合次数
+ mix_volume: float = 50.0 # 混合体积
+ touch_tip: bool = False # 接触壁
+ delay_after_aspirate: float = 0.5 # 吸液后延时
+ delay_after_dispense: float = 0.5 # 排液后延时
+
+
+class PipetteController:
+ """移液控制器"""
+
+ # 预定义液体参数
+ LIQUID_PARAMS = {
+ LiquidClass.WATER: LiquidParameters(
+ aspirate_speed=500,
+ dispense_speed=800,
+ air_gap=10.0
+ ),
+ LiquidClass.SERUM: LiquidParameters(
+ aspirate_speed=200,
+ dispense_speed=400,
+ air_gap=15.0,
+ pre_wet=True,
+ delay_after_aspirate=1.0
+ ),
+ LiquidClass.VISCOUS: LiquidParameters(
+ aspirate_speed=100,
+ dispense_speed=200,
+ air_gap=20.0,
+ delay_after_aspirate=2.0,
+ delay_after_dispense=2.0
+ ),
+ LiquidClass.VOLATILE: LiquidParameters(
+ aspirate_speed=800,
+ dispense_speed=1000,
+ air_gap=5.0,
+ delay_after_aspirate=0.2,
+ delay_after_dispense=0.2
+ )
+ }
+
+ def __init__(self, port: str, address: int = 4, xyz_port: Optional[str] = None):
+ """
+ 初始化移液控制器
+
+ Args:
+ port: 移液器串口端口
+ address: 移液器RS485地址
+ xyz_port: XYZ步进电机串口端口(可选,用于枪头装载等运动控制)
+ """
+ self.config = SOPAConfig(
+ port=port,
+ address=address,
+ baudrate=115200
+ )
+ self.pipette = SOPAPipette(self.config)
+ self.tip_status = TipStatus.NO_TIP
+ self.current_volume = 0.0
+ self.max_volume = 1000.0 # 默认1000ul
+ self.liquid_class = LiquidClass.WATER
+ self.liquid_params = self.LIQUID_PARAMS[LiquidClass.WATER]
+
+ # XYZ步进电机控制器(用于运动控制)
+ self.xyz_controller: Optional[XYZStepperController] = None
+ self.xyz_port = xyz_port
+ self.xyz_connected = False
+
+ # 统计信息
+ self.tip_count = 0
+ self.aspirate_count = 0
+ self.dispense_count = 0
+
+ def connect(self) -> bool:
+ """连接移液器和XYZ步进电机控制器"""
+ try:
+ # 连接移液器
+ if not self.pipette.connect():
+ logger.error("移液器连接失败")
+ return False
+ logger.info("移液器连接成功")
+
+ # 连接XYZ步进电机控制器(如果提供了端口)
+ if self.xyz_port:
+ try:
+ self.xyz_controller = XYZStepperController(self.xyz_port)
+ if self.xyz_controller.connect():
+ self.xyz_connected = True
+ logger.info(f"XYZ步进电机控制器连接成功: {self.xyz_port}")
+ else:
+ logger.warning(f"XYZ步进电机控制器连接失败: {self.xyz_port}")
+ self.xyz_controller = None
+ except Exception as e:
+ logger.warning(f"XYZ步进电机控制器连接异常: {e}")
+ self.xyz_controller = None
+ self.xyz_connected = False
+ else:
+ logger.info("未配置XYZ步进电机端口,跳过运动控制器连接")
+
+ return True
+ except Exception as e:
+ logger.error(f"设备连接失败: {e}")
+ return False
+
+ def initialize(self) -> bool:
+ """初始化移液器"""
+ try:
+ if self.pipette.initialize():
+ logger.info("移液器初始化成功")
+ # 检查枪头状态
+ self._update_tip_status()
+ return True
+ return False
+ except Exception as e:
+ logger.error(f"移液器初始化失败: {e}")
+ return False
+
+ def disconnect(self):
+ """断开连接"""
+ # 断开移液器连接
+ self.pipette.disconnect()
+ logger.info("移液器已断开")
+
+ # 断开 XYZ 步进电机连接
+ if self.xyz_controller and self.xyz_connected:
+ try:
+ self.xyz_controller.disconnect()
+ self.xyz_connected = False
+ logger.info("XYZ 步进电机已断开")
+ except Exception as e:
+ logger.error(f"断开 XYZ 步进电机失败: {e}")
+
+ def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
+ """
+ 检查 XYZ 轴移动的安全性
+
+ Args:
+ axis: 电机轴
+ target_position: 目标位置(步数)
+
+ Returns:
+ 是否安全
+ """
+ try:
+ # 获取当前电机状态
+ motor_position = self.xyz_controller.get_motor_status(axis)
+
+ # 检查电机状态是否正常 (不是碰撞停止或限位停止)
+ if motor_position.status in [MotorStatus.COLLISION_STOP,
+ MotorStatus.FORWARD_LIMIT_STOP,
+ MotorStatus.REVERSE_LIMIT_STOP]:
+ logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}")
+ return False
+
+ # 检查位置限制 (扩大安全范围以适应实际硬件)
+ # 步进电机的位置范围通常很大,这里设置更合理的范围
+ if target_position < -500000 or target_position > 500000:
+ logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}")
+ return False
+
+ # 检查移动距离是否过大 (单次移动不超过 20000 步,约12mm)
+ current_position = motor_position.steps
+ move_distance = abs(target_position - current_position)
+ if move_distance > 20000:
+ logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}步")
+ return False
+
+ return True
+
+ except Exception as e:
+ logger.error(f"安全检查失败: {e}")
+ return False
+
+ def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool:
+ """
+ Z轴相对移动
+
+ Args:
+ distance_mm: 移动距离(mm),正值向下,负值向上
+ speed: 移动速度(rpm)
+ acceleration: 加速度(rpm/s)
+
+ Returns:
+ 移动是否成功
+ """
+ if not self.xyz_controller or not self.xyz_connected:
+ logger.error("XYZ 步进电机未连接,无法执行移动")
+ return False
+
+ try:
+ # 参数验证
+ if abs(distance_mm) > 15.0:
+ logger.error(f"移动距离过大: {distance_mm}mm,最大允许15mm")
+ return False
+
+ if speed < 100 or speed > 5000:
+ logger.error(f"速度参数无效: {speed}rpm,范围应为100-5000")
+ return False
+
+ # 获取当前 Z 轴位置
+ current_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
+ current_z_position = current_status.steps
+
+ # 计算移动距离对应的步数 (1mm = 1638.4步)
+ mm_to_steps = 1638.4
+ move_distance_steps = int(distance_mm * mm_to_steps)
+
+ # 计算目标位置
+ target_z_position = current_z_position + move_distance_steps
+
+ # 安全检查
+ if not self._check_xyz_safety(MotorAxis.Z, target_z_position):
+ logger.error("Z轴移动安全检查失败")
+ return False
+
+ logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)")
+ logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}步")
+
+ # 执行移动
+ success = self.xyz_controller.move_to_position(
+ axis=MotorAxis.Z,
+ position=target_z_position,
+ speed=speed,
+ acceleration=acceleration,
+ precision=50
+ )
+
+ if not success:
+ logger.error("Z轴移动命令发送失败")
+ return False
+
+ # 等待移动完成
+ if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0):
+ logger.error("Z轴移动超时")
+ return False
+
+ # 验证移动结果
+ final_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
+ final_position = final_status.steps
+ position_error = abs(final_position - target_z_position)
+
+ logger.info(f"Z轴移动完成,最终位置: {final_position}步,误差: {position_error}步")
+
+ if position_error > 100:
+ logger.warning(f"Z轴位置误差较大: {position_error}步")
+
+ return True
+
+ except ModbusException as e:
+ logger.error(f"Modbus通信错误: {e}")
+ return False
+ except Exception as e:
+ logger.error(f"Z轴移动失败: {e}")
+ return False
+
+ def emergency_stop(self) -> bool:
+ """
+ 紧急停止所有运动
+
+ Returns:
+ 停止是否成功
+ """
+ success = True
+
+ # 停止移液器操作
+ try:
+ if self.pipette and self.connected:
+ # 这里可以添加移液器的紧急停止逻辑
+ logger.info("移液器紧急停止")
+ except Exception as e:
+ logger.error(f"移液器紧急停止失败: {e}")
+ success = False
+
+ # 停止 XYZ 轴运动
+ try:
+ if self.xyz_controller and self.xyz_connected:
+ self.xyz_controller.emergency_stop()
+ logger.info("XYZ 轴紧急停止")
+ except Exception as e:
+ logger.error(f"XYZ 轴紧急停止失败: {e}")
+ success = False
+
+ return success
+
+ def pickup_tip(self) -> bool:
+ """
+ 装载枪头 - Z轴向下移动10mm进行枪头装载
+
+ Returns:
+ 是否成功
+ """
+ if self.tip_status == TipStatus.TIP_ATTACHED:
+ logger.warning("已有枪头,无需重复装载")
+ return True
+
+ logger.info("开始装载枪头 - Z轴向下移动10mm")
+
+ # 使用相对移动方法,向下移动10mm
+ if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500):
+ # 更新枪头状态
+ self.tip_status = TipStatus.TIP_ATTACHED
+ self.tip_count += 1
+ self.current_volume = 0.0
+ logger.info("枪头装载成功")
+ return True
+ else:
+ logger.error("枪头装载失败 - Z轴移动失败")
+ return False
+
+ def eject_tip(self) -> bool:
+ """
+ 弹出枪头
+
+ Returns:
+ 是否成功
+ """
+ if self.tip_status == TipStatus.NO_TIP:
+ logger.warning("无枪头可弹出")
+ return True
+
+ try:
+ if self.pipette.eject_tip():
+ self.tip_status = TipStatus.NO_TIP
+ self.current_volume = 0.0
+ logger.info("枪头已弹出")
+ return True
+ return False
+ except Exception as e:
+ logger.error(f"弹出枪头失败: {e}")
+ return False
+
+ def aspirate(self, volume: float, liquid_class: Optional[LiquidClass] = None,
+ detection: bool = True) -> bool:
+ """
+ 吸液
+
+ Args:
+ volume: 吸液体积(ul)
+ liquid_class: 液体类型
+ detection: 是否开启液位检测
+
+ Returns:
+ 是否成功
+ """
+ if self.tip_status != TipStatus.TIP_ATTACHED:
+ logger.error("无枪头,无法吸液")
+ return False
+
+ if self.current_volume + volume > self.max_volume:
+ logger.error(f"吸液量超过枪头容量: {self.current_volume + volume} > {self.max_volume}")
+ return False
+
+ # 设置液体参数
+ if liquid_class:
+ self.set_liquid_class(liquid_class)
+
+ try:
+ # 设置吸液速度
+ self.pipette.set_max_speed(self.liquid_params.aspirate_speed)
+
+ # 执行液位检测
+ if detection:
+ if not self.pipette.liquid_level_detection():
+ logger.warning("液位检测失败,继续吸液")
+
+ # 预润湿
+ if self.liquid_params.pre_wet and self.current_volume == 0:
+ logger.info("执行预润湿")
+ self._pre_wet(volume * 0.2)
+
+ # 吸液
+ if self.pipette.aspirate(volume, detection=False):
+ self.current_volume += volume
+ self.aspirate_count += 1
+
+ # 吸液后延时
+ time.sleep(self.liquid_params.delay_after_aspirate)
+
+ # 吸取空气间隙
+ if self.liquid_params.air_gap > 0:
+ self.pipette.aspirate(self.liquid_params.air_gap, detection=False)
+ self.current_volume += self.liquid_params.air_gap
+
+ logger.info(f"吸液完成: {volume}ul, 当前体积: {self.current_volume}ul")
+ return True
+ else:
+ logger.error("吸液失败")
+ return False
+
+ except Exception as e:
+ logger.error(f"吸液异常: {e}")
+ return False
+
+ def dispense(self, volume: float, blow_out: bool = False) -> bool:
+ """
+ 排液
+
+ Args:
+ volume: 排液体积(ul)
+ blow_out: 是否吹出
+
+ Returns:
+ 是否成功
+ """
+ if self.tip_status != TipStatus.TIP_ATTACHED:
+ logger.error("无枪头,无法排液")
+ return False
+
+ if volume > self.current_volume:
+ logger.error(f"排液量超过当前体积: {volume} > {self.current_volume}")
+ return False
+
+ try:
+ # 设置排液速度
+ self.pipette.set_max_speed(self.liquid_params.dispense_speed)
+
+ # 排液
+ if self.pipette.dispense(volume):
+ self.current_volume -= volume
+ self.dispense_count += 1
+
+ # 排液后延时
+ time.sleep(self.liquid_params.delay_after_dispense)
+
+ # 吹出
+ if blow_out and self.liquid_params.blow_out > 0:
+ self.pipette.dispense(self.liquid_params.blow_out)
+ logger.debug(f"执行吹出: {self.liquid_params.blow_out}ul")
+
+ # 接触壁
+ if self.liquid_params.touch_tip:
+ self._touch_tip()
+
+ logger.info(f"排液完成: {volume}ul, 剩余体积: {self.current_volume}ul")
+ return True
+ else:
+ logger.error("排液失败")
+ return False
+
+ except Exception as e:
+ logger.error(f"排液异常: {e}")
+ return False
+
+ def transfer(self, volume: float,
+ source_well: Optional[str] = None,
+ dest_well: Optional[str] = None,
+ liquid_class: Optional[LiquidClass] = None,
+ new_tip: bool = True,
+ mix_before: Optional[Tuple[int, float]] = None,
+ mix_after: Optional[Tuple[int, float]] = None) -> bool:
+ """
+ 液体转移
+
+ Args:
+ volume: 转移体积
+ source_well: 源孔位
+ dest_well: 目标孔位
+ liquid_class: 液体类型
+ new_tip: 是否使用新枪头
+ mix_before: 吸液前混合(次数, 体积)
+ mix_after: 排液后混合(次数, 体积)
+
+ Returns:
+ 是否成功
+ """
+ try:
+ # 装载新枪头
+ if new_tip:
+ self.eject_tip()
+ if not self.pickup_tip():
+ return False
+
+ # 设置液体类型
+ if liquid_class:
+ self.set_liquid_class(liquid_class)
+
+ # 吸液前混合
+ if mix_before:
+ cycles, mix_vol = mix_before
+ self.mix(cycles, mix_vol)
+
+ # 吸液
+ if not self.aspirate(volume):
+ return False
+
+ # 排液
+ if not self.dispense(volume, blow_out=True):
+ return False
+
+ # 排液后混合
+ if mix_after:
+ cycles, mix_vol = mix_after
+ self.mix(cycles, mix_vol)
+
+ logger.info(f"液体转移完成: {volume}ul")
+ return True
+
+ except Exception as e:
+ logger.error(f"液体转移失败: {e}")
+ return False
+
+ def mix(self, cycles: int = 3, volume: Optional[float] = None) -> bool:
+ """
+ 混合
+
+ Args:
+ cycles: 混合次数
+ volume: 混合体积
+
+ Returns:
+ 是否成功
+ """
+ volume = volume or self.liquid_params.mix_volume
+
+ logger.info(f"开始混合: {cycles}次, {volume}ul")
+
+ for i in range(cycles):
+ if not self.aspirate(volume, detection=False):
+ return False
+ if not self.dispense(volume):
+ return False
+
+ logger.info("混合完成")
+ return True
+
+ def _pre_wet(self, volume: float):
+ """预润湿"""
+ self.pipette.aspirate(volume, detection=False)
+ time.sleep(0.2)
+ self.pipette.dispense(volume)
+ time.sleep(0.2)
+
+ def _touch_tip(self):
+ """接触壁(需要与运动控制配合)"""
+ # TODO: 实现接触壁动作
+ logger.debug("执行接触壁")
+ time.sleep(0.5)
+
+ def _update_tip_status(self):
+ """更新枪头状态"""
+ if self.pipette.get_tip_status():
+ self.tip_status = TipStatus.TIP_ATTACHED
+ else:
+ self.tip_status = TipStatus.NO_TIP
+
+ def set_liquid_class(self, liquid_class: LiquidClass):
+ """设置液体类型"""
+ self.liquid_class = liquid_class
+ if liquid_class in self.LIQUID_PARAMS:
+ self.liquid_params = self.LIQUID_PARAMS[liquid_class]
+ logger.info(f"液体类型设置为: {liquid_class.value}")
+
+ def set_custom_parameters(self, params: LiquidParameters):
+ """设置自定义液体参数"""
+ self.liquid_params = params
+ self.liquid_class = LiquidClass.CUSTOM
+
+ def calibrate_volume(self, expected: float, actual: float):
+ """
+ 体积校准
+
+ Args:
+ expected: 期望体积
+ actual: 实际体积
+ """
+ factor = actual / expected
+ self.pipette.set_calibration_factor(factor)
+ logger.info(f"体积校准系数: {factor}")
+
+ def get_status(self) -> Dict:
+ """获取状态信息"""
+ return {
+ 'tip_status': self.tip_status.value,
+ 'current_volume': self.current_volume,
+ 'max_volume': self.max_volume,
+ 'liquid_class': self.liquid_class.value,
+ 'statistics': {
+ 'tip_count': self.tip_count,
+ 'aspirate_count': self.aspirate_count,
+ 'dispense_count': self.dispense_count
+ }
+ }
+
+ def reset_statistics(self):
+ """重置统计信息"""
+ self.tip_count = 0
+ self.aspirate_count = 0
+ self.dispense_count = 0
+
+# ============================================================================
+# 实例化代码块 - 移液控制器使用示例
+# ============================================================================
+
+if __name__ == "__main__":
+ # 配置日志
+ import logging
+
+ # 设置日志级别
+ logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+ )
+
+ def interactive_test():
+ """交互式测试模式 - 适用于已连接的设备"""
+ print("\n" + "=" * 60)
+ print("🧪 移液器交互式测试模式")
+ print("=" * 60)
+
+ # 获取用户输入的连接参数
+ print("\n📡 设备连接配置:")
+ port = input("请输入移液器串口端口 (默认: /dev/ttyUSB0): ").strip() or "/dev/ttyUSB0"
+ address_input = input("请输入移液器设备地址 (默认: 4): ").strip()
+ address = int(address_input) if address_input else 4
+
+ # 询问是否连接 XYZ 步进电机控制器
+ xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower()
+ xyz_port = None
+ if xyz_enable in ['y', 'yes']:
+ xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB1): ").strip() or "/dev/ttyUSB1"
+
+ try:
+ # 创建移液控制器实例
+ if xyz_port:
+ print(f"\n🔧 创建移液控制器实例 (移液器端口: {port}, 地址: {address}, XYZ端口: {xyz_port})...")
+ pipette = PipetteController(port=port, address=address, xyz_port=xyz_port)
+ else:
+ print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...")
+ pipette = PipetteController(port=port, address=address)
+
+ # 连接设备
+ print("\n📞 连接移液器设备...")
+ if not pipette.connect():
+ print("❌ 设备连接失败,请检查连接")
+ return
+ print("✅ 设备连接成功")
+
+ # 初始化设备
+ print("\n🚀 初始化设备...")
+ if not pipette.initialize():
+ print("❌ 设备初始化失败")
+ return
+ print("✅ 设备初始化成功")
+
+ # 交互式菜单
+ while True:
+ print("\n" + "=" * 50)
+ print("🎮 交互式操作菜单:")
+ print("1. 📋 查看设备状态")
+ print("2. 🔧 装载枪头")
+ print("3. 🗑️ 弹出枪头")
+ print("4. 💧 吸液操作")
+ print("5. 💦 排液操作")
+ print("6. 🌀 混合操作")
+ print("7. 🔄 液体转移")
+ print("8. ⚙️ 设置液体类型")
+ print("9. 🎯 自定义参数")
+ print("10. 📊 校准体积")
+ print("11. 🧹 重置统计")
+ print("12. 🔍 液体类型测试")
+ print("99. 🚨 紧急停止")
+ print("0. 🚪 退出程序")
+ print("=" * 50)
+
+ choice = input("\n请选择操作 (0-12, 99): ").strip()
+
+ if choice == "0":
+ print("\n👋 退出程序...")
+ break
+ elif choice == "1":
+ # 查看设备状态
+ status = pipette.get_status()
+ print("\n📊 设备状态信息:")
+ print(f" 🎯 枪头状态: {status['tip_status']}")
+ print(f" 💧 当前体积: {status['current_volume']}ul")
+ print(f" 📏 最大体积: {status['max_volume']}ul")
+ print(f" 🧪 液体类型: {status['liquid_class']}")
+ print(f" 📈 统计信息:")
+ print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
+ print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
+ print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
+
+ elif choice == "2":
+ # 装载枪头
+ print("\n🔧 装载枪头...")
+ if pipette.xyz_connected:
+ print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)")
+ else:
+ print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载")
+
+ if pipette.pickup_tip():
+ print("✅ 枪头装载成功")
+ if pipette.xyz_connected:
+ print("📍 Z 轴已移动到装载位置")
+ else:
+ print("❌ 枪头装载失败")
+
+ elif choice == "3":
+ # 弹出枪头
+ print("\n🗑️ 弹出枪头...")
+ if pipette.eject_tip():
+ print("✅ 枪头弹出成功")
+ else:
+ print("❌ 枪头弹出失败")
+
+ elif choice == "4":
+ # 吸液操作
+ try:
+ volume = float(input("请输入吸液体积 (ul): "))
+ detection = input("是否启用液面检测? (y/n, 默认y): ").strip().lower() != 'n'
+ print(f"\n💧 执行吸液操作 ({volume}ul)...")
+ if pipette.aspirate(volume, detection=detection):
+ print(f"✅ 吸液成功: {volume}ul")
+ print(f"📊 当前体积: {pipette.current_volume}ul")
+ else:
+ print("❌ 吸液失败")
+ except ValueError:
+ print("❌ 请输入有效的数字")
+
+ elif choice == "5":
+ # 排液操作
+ try:
+ volume = float(input("请输入排液体积 (ul): "))
+ blow_out = input("是否执行吹出操作? (y/n, 默认n): ").strip().lower() == 'y'
+ print(f"\n💦 执行排液操作 ({volume}ul)...")
+ if pipette.dispense(volume, blow_out=blow_out):
+ print(f"✅ 排液成功: {volume}ul")
+ print(f"📊 剩余体积: {pipette.current_volume}ul")
+ else:
+ print("❌ 排液失败")
+ except ValueError:
+ print("❌ 请输入有效的数字")
+
+ elif choice == "6":
+ # 混合操作
+ try:
+ cycles = int(input("请输入混合次数 (默认3): ") or "3")
+ volume_input = input("请输入混合体积 (ul, 默认使用当前体积的50%): ").strip()
+ volume = float(volume_input) if volume_input else None
+ print(f"\n🌀 执行混合操作 ({cycles}次)...")
+ if pipette.mix(cycles=cycles, volume=volume):
+ print("✅ 混合完成")
+ else:
+ print("❌ 混合失败")
+ except ValueError:
+ print("❌ 请输入有效的数字")
+
+ elif choice == "7":
+ # 液体转移
+ try:
+ volume = float(input("请输入转移体积 (ul): "))
+ source = input("源孔位 (可选, 如A1): ").strip() or None
+ dest = input("目标孔位 (可选, 如B1): ").strip() or None
+ new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n'
+
+ print(f"\n🔄 执行液体转移 ({volume}ul)...")
+ if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip):
+ print("✅ 液体转移完成")
+ else:
+ print("❌ 液体转移失败")
+ except ValueError:
+ print("❌ 请输入有效的数字")
+
+ elif choice == "8":
+ # 设置液体类型
+ print("\n🧪 可用液体类型:")
+ liquid_options = {
+ "1": (LiquidClass.WATER, "水溶液"),
+ "2": (LiquidClass.SERUM, "血清"),
+ "3": (LiquidClass.VISCOUS, "粘稠液体"),
+ "4": (LiquidClass.VOLATILE, "挥发性液体")
+ }
+
+ for key, (liquid_class, description) in liquid_options.items():
+ print(f" {key}. {description}")
+
+ liquid_choice = input("请选择液体类型 (1-4): ").strip()
+ if liquid_choice in liquid_options:
+ liquid_class, description = liquid_options[liquid_choice]
+ pipette.set_liquid_class(liquid_class)
+ print(f"✅ 液体类型设置为: {description}")
+
+ # 显示参数
+ params = pipette.liquid_params
+ print(f"📋 参数设置:")
+ print(f" ⬆️ 吸液速度: {params.aspirate_speed}")
+ print(f" ⬇️ 排液速度: {params.dispense_speed}")
+ print(f" 💨 空气间隙: {params.air_gap}ul")
+ print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}")
+ else:
+ print("❌ 无效选择")
+
+ elif choice == "9":
+ # 自定义参数
+ try:
+ print("\n⚙️ 设置自定义参数 (直接回车使用默认值):")
+ aspirate_speed = input("吸液速度 (默认500): ").strip()
+ dispense_speed = input("排液速度 (默认800): ").strip()
+ air_gap = input("空气间隙 (ul, 默认10.0): ").strip()
+ pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y'
+
+ custom_params = LiquidParameters(
+ aspirate_speed=int(aspirate_speed) if aspirate_speed else 500,
+ dispense_speed=int(dispense_speed) if dispense_speed else 800,
+ air_gap=float(air_gap) if air_gap else 10.0,
+ pre_wet=pre_wet
+ )
+
+ pipette.set_custom_parameters(custom_params)
+ print("✅ 自定义参数设置完成")
+ except ValueError:
+ print("❌ 请输入有效的数字")
+
+ elif choice == "10":
+ # 校准体积
+ try:
+ expected = float(input("期望体积 (ul): "))
+ actual = float(input("实际测量体积 (ul): "))
+ pipette.calibrate_volume(expected, actual)
+ print(f"✅ 校准完成,校准系数: {actual/expected:.3f}")
+ except ValueError:
+ print("❌ 请输入有效的数字")
+
+ elif choice == "11":
+ # 重置统计
+ pipette.reset_statistics()
+ print("✅ 统计信息已重置")
+
+ elif choice == "12":
+ # 液体类型测试
+ print("\n🧪 液体类型参数对比:")
+ liquid_tests = [
+ (LiquidClass.WATER, "水溶液"),
+ (LiquidClass.SERUM, "血清"),
+ (LiquidClass.VISCOUS, "粘稠液体"),
+ (LiquidClass.VOLATILE, "挥发性液体")
+ ]
+
+ for liquid_class, description in liquid_tests:
+ params = pipette.LIQUID_PARAMS[liquid_class]
+ print(f"\n📋 {description} ({liquid_class.value}):")
+ print(f" ⬆️ 吸液速度: {params.aspirate_speed}")
+ print(f" ⬇️ 排液速度: {params.dispense_speed}")
+ print(f" 💨 空气间隙: {params.air_gap}ul")
+ print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}")
+ print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s")
+
+ elif choice == "99":
+ # 紧急停止
+ print("\n🚨 执行紧急停止...")
+ success = pipette.emergency_stop()
+ if success:
+ print("✅ 紧急停止执行成功")
+ print("⚠️ 所有运动已停止,请检查设备状态")
+ else:
+ print("❌ 紧急停止执行失败")
+ print("⚠️ 请手动检查设备状态并采取必要措施")
+
+ # 紧急停止后询问是否继续
+ continue_choice = input("\n是否继续操作?(y/n): ").strip().lower()
+ if continue_choice != 'y':
+ print("🚪 退出程序")
+ break
+
+ else:
+ print("❌ 无效选择,请重新输入")
+
+ # 等待用户确认继续
+ input("\n按回车键继续...")
+
+ except KeyboardInterrupt:
+ print("\n\n⚠️ 用户中断操作")
+ except Exception as e:
+ print(f"\n❌ 发生异常: {e}")
+ finally:
+ # 断开连接
+ print("\n📞 断开设备连接...")
+ try:
+ pipette.disconnect()
+ print("✅ 连接已断开")
+ except:
+ print("⚠️ 断开连接时出现问题")
+
+ def demo_test():
+ """演示测试模式 - 完整功能演示"""
+ print("\n" + "=" * 60)
+ print("🎬 移液控制器演示测试")
+ print("=" * 60)
+
+ try:
+ # 创建移液控制器实例
+ print("1. 🔧 创建移液控制器实例...")
+ pipette = PipetteController(port="/dev/ttyUSB0", address=4)
+ print("✅ 移液控制器实例创建成功")
+
+ # 连接设备
+ print("\n2. 📞 连接移液器设备...")
+ if pipette.connect():
+ print("✅ 设备连接成功")
+ else:
+ print("❌ 设备连接失败")
+ return False
+
+ # 初始化设备
+ print("\n3. 🚀 初始化设备...")
+ if pipette.initialize():
+ print("✅ 设备初始化成功")
+ else:
+ print("❌ 设备初始化失败")
+ return False
+
+ # 装载枪头
+ print("\n4. 🔧 装载枪头...")
+ if pipette.pickup_tip():
+ print("✅ 枪头装载成功")
+ else:
+ print("❌ 枪头装载失败")
+
+ # 设置液体类型
+ print("\n5. 🧪 设置液体类型为血清...")
+ pipette.set_liquid_class(LiquidClass.SERUM)
+ print("✅ 液体类型设置完成")
+
+ # 吸液操作
+ print("\n6. 💧 执行吸液操作...")
+ volume_to_aspirate = 100.0
+ if pipette.aspirate(volume_to_aspirate, detection=True):
+ print(f"✅ 吸液成功: {volume_to_aspirate}ul")
+ print(f"📊 当前体积: {pipette.current_volume}ul")
+ else:
+ print("❌ 吸液失败")
+
+ # 排液操作
+ print("\n7. 💦 执行排液操作...")
+ volume_to_dispense = 50.0
+ if pipette.dispense(volume_to_dispense, blow_out=True):
+ print(f"✅ 排液成功: {volume_to_dispense}ul")
+ print(f"📊 剩余体积: {pipette.current_volume}ul")
+ else:
+ print("❌ 排液失败")
+
+ # 混合操作
+ print("\n8. 🌀 执行混合操作...")
+ if pipette.mix(cycles=3, volume=30.0):
+ print("✅ 混合完成")
+ else:
+ print("❌ 混合失败")
+
+ # 获取状态信息
+ print("\n9. 📊 获取设备状态...")
+ status = pipette.get_status()
+ print("设备状态信息:")
+ print(f" 🎯 枪头状态: {status['tip_status']}")
+ print(f" 💧 当前体积: {status['current_volume']}ul")
+ print(f" 📏 最大体积: {status['max_volume']}ul")
+ print(f" 🧪 液体类型: {status['liquid_class']}")
+ print(f" 📈 统计信息:")
+ print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
+ print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
+ print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
+
+ # 弹出枪头
+ print("\n10. 🗑️ 弹出枪头...")
+ if pipette.eject_tip():
+ print("✅ 枪头弹出成功")
+ else:
+ print("❌ 枪头弹出失败")
+
+ print("\n" + "=" * 60)
+ print("✅ 移液控制器演示测试完成")
+ print("=" * 60)
+
+ return True
+
+ except Exception as e:
+ print(f"\n❌ 测试过程中发生异常: {e}")
+ return False
+
+ finally:
+ # 断开连接
+ print("\n📞 断开连接...")
+ pipette.disconnect()
+ print("✅ 连接已断开")
+
+ # 主程序入口
+ print("🧪 移液器控制器测试程序")
+ print("=" * 40)
+ print("1. 🎮 交互式测试 (推荐)")
+ print("2. 🎬 演示测试")
+ print("0. 🚪 退出")
+ print("=" * 40)
+
+ mode = input("请选择测试模式 (0-2): ").strip()
+
+ if mode == "1":
+ interactive_test()
+ elif mode == "2":
+ demo_test()
+ elif mode == "0":
+ print("👋 再见!")
+ else:
+ print("❌ 无效选择")
+
+ print("\n🎉 程序结束!")
+ print("\n💡 使用说明:")
+ print("1. 确保移液器硬件已正确连接")
+ print("2. 根据实际情况修改串口端口号")
+ print("3. 交互模式支持实时操作和参数调整")
+ print("4. 在实际使用中需要配合运动控制器进行位置移动")
diff --git a/unilabos/devices/laiyu_liquid/controllers/xyz_controller.py b/unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
new file mode 100644
index 00000000..2526f485
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
@@ -0,0 +1,1183 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+XYZ三轴步进电机控制器
+支持坐标系管理、限位开关回零、工作原点设定等功能
+
+主要功能:
+- 坐标系转换层(步数↔毫米)
+- 限位开关回零功能
+- 工作原点示教和保存
+- 安全限位检查
+- 运动控制接口
+
+"""
+
+import json
+import os
+import time
+from typing import Optional, Dict, Tuple, Union
+from dataclasses import dataclass, asdict
+from pathlib import Path
+import logging
+
+# 添加项目根目录到Python路径以解决模块导入问题
+import sys
+import os
+
+# 无论如何都添加项目根目录到路径
+current_file = os.path.abspath(__file__)
+# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/xyz_controller.py
+# 向上5级到 .../Uni-Lab-OS
+project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
+# 强制添加项目根目录到sys.path的开头
+sys.path.insert(0, project_root)
+
+# 导入原有的驱动
+from unilabos.devices.laiyu_liquid.drivers.xyz_stepper_driver import XYZStepperController, MotorAxis, MotorStatus
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class MachineConfig:
+ """机械配置参数"""
+ # 步距配置 (基于16384步/圈的步进电机)
+ steps_per_mm_x: float = 204.8 # X轴步距 (16384步/圈 ÷ 80mm导程)
+ steps_per_mm_y: float = 204.8 # Y轴步距 (16384步/圈 ÷ 80mm导程)
+ steps_per_mm_z: float = 3276.8 # Z轴步距 (16384步/圈 ÷ 5mm导程)
+
+ # 行程限制
+ max_travel_x: float = 340.0 # X轴最大行程
+ max_travel_y: float = 250.0 # Y轴最大行程
+ max_travel_z: float = 160.0 # Z轴最大行程
+
+ # 安全移动参数
+ safe_z_height: float = 0.0 # Z轴安全移动高度 (mm) - 液体处理工作站安全高度
+ z_approach_height: float = 5.0 # Z轴接近高度 (mm) - 在目标位置上方的预备高度
+
+ # 回零参数
+ homing_speed: int = 100 # 回零速度 (rpm)
+ homing_timeout: float = 30.0 # 回零超时时间
+ safe_clearance: float = 1.0 # 安全间隙 (mm)
+ position_stable_time: float = 3.0 # 位置稳定检测时间(秒)
+ position_check_interval: float = 0.2 # 位置检查间隔(秒)
+
+ # 运动参数
+ default_speed: int = 100 # 默认运动速度 (rpm)
+ default_acceleration: int = 1000 # 默认加速度
+
+
+@dataclass
+class CoordinateOrigin:
+ """坐标原点信息"""
+ machine_origin_steps: Dict[str, int] = None # 机械原点步数位置
+ work_origin_steps: Dict[str, int] = None # 工作原点步数位置
+ is_homed: bool = False # 是否已回零
+ timestamp: str = "" # 设定时间戳
+
+ def __post_init__(self):
+ if self.machine_origin_steps is None:
+ self.machine_origin_steps = {"x": 0, "y": 0, "z": 0}
+ if self.work_origin_steps is None:
+ self.work_origin_steps = {"x": 0, "y": 0, "z": 0}
+
+
+class CoordinateSystemError(Exception):
+ """坐标系统异常"""
+ pass
+
+
+class XYZController(XYZStepperController):
+ """XYZ三轴控制器"""
+
+ def __init__(self, port: str, baudrate: int = 115200,
+ machine_config: Optional[MachineConfig] = None,
+ config_file: str = "machine_config.json",
+ auto_connect: bool = True):
+ """
+ 初始化XYZ控制器
+
+ Args:
+ port: 串口端口
+ baudrate: 波特率
+ machine_config: 机械配置参数
+ config_file: 配置文件路径
+ auto_connect: 是否自动连接设备
+ """
+ super().__init__(port, baudrate)
+
+ # 机械配置
+ self.machine_config = machine_config or MachineConfig()
+ self.config_file = config_file
+
+ # 坐标系统
+ self.coordinate_origin = CoordinateOrigin()
+ self.origin_file = "coordinate_origin.json"
+
+ # 连接状态
+ self.is_connected = False
+
+ # 加载配置
+ self._load_config()
+ self._load_coordinate_origin()
+
+ # 自动连接设备
+ if auto_connect:
+ self.connect_device()
+
+ def connect_device(self) -> bool:
+ """
+ 连接设备并初始化
+
+ Returns:
+ bool: 连接是否成功
+ """
+ try:
+ logger.info(f"正在连接设备: {self.port}")
+
+ # 连接硬件
+ if not self.connect():
+ logger.error("硬件连接失败")
+ return False
+
+ self.is_connected = True
+ logger.info("设备连接成功")
+
+ # 使能所有轴
+ enable_results = self.enable_all_axes(True)
+ success_count = sum(1 for result in enable_results.values() if result)
+ logger.info(f"轴使能结果: {success_count}/{len(enable_results)} 成功")
+
+ # 获取系统状态
+ try:
+ status = self.get_system_status()
+ logger.info(f"系统状态获取成功: {len(status)} 项信息")
+ except Exception as e:
+ logger.warning(f"获取系统状态失败: {e}")
+
+ return True
+
+ except Exception as e:
+ logger.error(f"设备连接失败: {e}")
+ self.is_connected = False
+ return False
+
+ def disconnect_device(self):
+ """断开设备连接"""
+ try:
+ if self.is_connected:
+ self.disconnect() # 使用父类的disconnect方法
+ self.is_connected = False
+ logger.info("设备连接已断开")
+ except Exception as e:
+ logger.error(f"断开连接失败: {e}")
+
+ def _load_config(self):
+ """加载机械配置"""
+ try:
+ if os.path.exists(self.config_file):
+ with open(self.config_file, 'r', encoding='utf-8') as f:
+ config_data = json.load(f)
+ # 更新配置参数
+ for key, value in config_data.items():
+ if hasattr(self.machine_config, key):
+ setattr(self.machine_config, key, value)
+ logger.info("机械配置加载完成")
+ except Exception as e:
+ logger.warning(f"加载机械配置失败: {e},使用默认配置")
+
+ def _save_config(self):
+ """保存机械配置"""
+ try:
+ with open(self.config_file, 'w', encoding='utf-8') as f:
+ json.dump(asdict(self.machine_config), f, indent=2, ensure_ascii=False)
+ logger.info("机械配置保存完成")
+ except Exception as e:
+ logger.error(f"保存机械配置失败: {e}")
+
+ def _load_coordinate_origin(self):
+ """加载坐标原点信息"""
+ try:
+ if os.path.exists(self.origin_file):
+ with open(self.origin_file, 'r', encoding='utf-8') as f:
+ origin_data = json.load(f)
+ self.coordinate_origin = CoordinateOrigin(**origin_data)
+ logger.info("坐标原点信息加载完成")
+ except Exception as e:
+ logger.warning(f"加载坐标原点失败: {e},使用默认设置")
+
+ def _save_coordinate_origin(self):
+ """保存坐标原点信息"""
+ try:
+ # 更新时间戳
+ from datetime import datetime
+ self.coordinate_origin.timestamp = datetime.now().isoformat()
+
+ with open(self.origin_file, 'w', encoding='utf-8') as f:
+ json.dump(asdict(self.coordinate_origin), f, indent=2, ensure_ascii=False)
+ logger.info("坐标原点信息保存完成")
+ except Exception as e:
+ logger.error(f"保存坐标原点失败: {e}")
+
+ # ==================== 坐标转换方法 ====================
+
+ def mm_to_steps(self, axis: MotorAxis, mm: float) -> int:
+ """毫米转步数"""
+ if axis == MotorAxis.X:
+ return int(mm * self.machine_config.steps_per_mm_x)
+ elif axis == MotorAxis.Y:
+ return int(mm * self.machine_config.steps_per_mm_y)
+ elif axis == MotorAxis.Z:
+ return int(mm * self.machine_config.steps_per_mm_z)
+ else:
+ raise ValueError(f"未知轴: {axis}")
+
+ def steps_to_mm(self, axis: MotorAxis, steps: int) -> float:
+ """步数转毫米"""
+ if axis == MotorAxis.X:
+ return steps / self.machine_config.steps_per_mm_x
+ elif axis == MotorAxis.Y:
+ return steps / self.machine_config.steps_per_mm_y
+ elif axis == MotorAxis.Z:
+ return steps / self.machine_config.steps_per_mm_z
+ else:
+ raise ValueError(f"未知轴: {axis}")
+
+ def work_to_machine_steps(self, x: float = None, y: float = None, z: float = None) -> Dict[str, int]:
+ """工作坐标转机械坐标步数"""
+ machine_steps = {}
+
+ if x is not None:
+ work_steps = self.mm_to_steps(MotorAxis.X, x)
+ machine_steps['x'] = self.coordinate_origin.work_origin_steps['x'] + work_steps
+
+ if y is not None:
+ work_steps = self.mm_to_steps(MotorAxis.Y, y)
+ machine_steps['y'] = self.coordinate_origin.work_origin_steps['y'] + work_steps
+
+ if z is not None:
+ work_steps = self.mm_to_steps(MotorAxis.Z, z)
+ machine_steps['z'] = self.coordinate_origin.work_origin_steps['z'] + work_steps
+
+ return machine_steps
+
+ def machine_to_work_coords(self, machine_steps: Dict[str, int]) -> Dict[str, float]:
+ """机械坐标步数转工作坐标"""
+ work_coords = {}
+
+ for axis_name, steps in machine_steps.items():
+ axis = MotorAxis[axis_name.upper()]
+ work_origin_steps = self.coordinate_origin.work_origin_steps[axis_name]
+ relative_steps = steps - work_origin_steps
+ work_coords[axis_name] = self.steps_to_mm(axis, relative_steps)
+
+ return work_coords
+
+ def check_travel_limits(self, x: float = None, y: float = None, z: float = None) -> bool:
+ """检查行程限制"""
+ if x is not None and (x < 0 or x > self.machine_config.max_travel_x):
+ raise CoordinateSystemError(f"X轴超出行程范围: {x}mm (0 ~ {self.machine_config.max_travel_x}mm)")
+
+ if y is not None and (y < 0 or y > self.machine_config.max_travel_y):
+ raise CoordinateSystemError(f"Y轴超出行程范围: {y}mm (0 ~ {self.machine_config.max_travel_y}mm)")
+
+ if z is not None and (z < 0 or z > self.machine_config.max_travel_z):
+ raise CoordinateSystemError(f"Z轴超出行程范围: {z}mm (0 ~ {self.machine_config.max_travel_z}mm)")
+
+ return True
+
+ # ==================== 回零和原点设定方法 ====================
+
+ def home_axis(self, axis: MotorAxis, direction: int = -1) -> bool:
+ """
+ 单轴回零到限位开关 - 使用步数变化检测
+
+ Args:
+ axis: 要回零的轴
+ direction: 回零方向 (-1负方向, 1正方向)
+
+ Returns:
+ bool: 回零是否成功
+ """
+ if not self.is_connected:
+ logger.error("设备未连接,无法执行回零操作")
+ return False
+
+ try:
+ logger.info(f"开始{axis.name}轴回零")
+
+ # 使能电机
+ if not self.enable_motor(axis, True):
+ raise CoordinateSystemError(f"{axis.name}轴使能失败")
+
+ # 设置回零速度模式,根据方向设置正负
+ speed = self.machine_config.homing_speed * direction
+ if not self.set_speed_mode(axis, speed):
+ raise CoordinateSystemError(f"{axis.name}轴设置回零速度失败")
+
+
+
+ # 智能回零检测 - 基于步数变化
+ start_time = time.time()
+ limit_detected = False
+ final_position = None
+
+ # 步数变化检测参数(从配置获取)
+ position_stable_time = self.machine_config.position_stable_time
+ check_interval = self.machine_config.position_check_interval
+ last_position = None
+ stable_start_time = None
+
+ logger.info(f"{axis.name}轴开始移动,监测步数变化...")
+
+ while time.time() - start_time < self.machine_config.homing_timeout:
+ status = self.get_motor_status(axis)
+ current_position = status.steps
+
+ # 检查是否明确触碰限位开关
+ if (direction < 0 and status.status == MotorStatus.REVERSE_LIMIT_STOP) or \
+ (direction > 0 and status.status == MotorStatus.FORWARD_LIMIT_STOP):
+ # 停止运动
+ self.emergency_stop(axis)
+ time.sleep(0.5)
+
+ # 记录机械原点位置
+ final_position = current_position
+ limit_detected = True
+ logger.info(f"{axis.name}轴检测到限位开关信号,位置: {final_position}步")
+ break
+
+ # 检查是否发生碰撞
+ if status.status == MotorStatus.COLLISION_STOP:
+ raise CoordinateSystemError(f"{axis.name}轴回零时发生碰撞")
+
+ # 步数变化检测逻辑
+ if last_position is not None:
+ # 检查位置是否发生变化
+ if abs(current_position - last_position) <= 1: # 允许1步的误差
+ # 位置基本没有变化
+ if stable_start_time is None:
+ stable_start_time = time.time()
+ logger.debug(f"{axis.name}轴位置开始稳定在 {current_position}步")
+ elif time.time() - stable_start_time >= position_stable_time:
+ # 位置稳定超过指定时间,认为已到达限位
+ self.emergency_stop(axis)
+ time.sleep(0.5)
+
+ final_position = current_position
+ limit_detected = True
+ logger.info(f"{axis.name}轴位置稳定{position_stable_time}秒,假设已到达限位开关,位置: {final_position}步")
+ break
+ else:
+ # 位置发生变化,重置稳定计时
+ stable_start_time = None
+ logger.debug(f"{axis.name}轴位置变化: {last_position} -> {current_position}")
+
+ last_position = current_position
+ time.sleep(check_interval)
+
+ # 超时处理
+ if not limit_detected:
+ logger.warning(f"{axis.name}轴回零超时({self.machine_config.homing_timeout}秒),强制停止")
+ self.emergency_stop(axis)
+ time.sleep(0.5)
+
+ # 获取当前位置作为机械原点
+ try:
+ status = self.get_motor_status(axis)
+ final_position = status.steps
+ logger.info(f"{axis.name}轴超时后位置: {final_position}步")
+ except Exception as e:
+ logger.error(f"获取{axis.name}轴位置失败: {e}")
+ return False
+
+ # 记录机械原点位置
+ self.coordinate_origin.machine_origin_steps[axis.name.lower()] = final_position
+
+ # 从限位开关退出安全距离
+ try:
+ clearance_steps = self.mm_to_steps(axis, self.machine_config.safe_clearance)
+ safe_position = final_position + (clearance_steps * -direction) # 反方向退出
+
+ if not self.move_to_position(axis, safe_position,
+ self.machine_config.default_speed):
+ logger.warning(f"{axis.name}轴无法退出到安全位置")
+ else:
+ self.wait_for_completion(axis, 10.0)
+ logger.info(f"{axis.name}轴已退出到安全位置: {safe_position}步")
+ except Exception as e:
+ logger.warning(f"{axis.name}轴退出安全位置时出错: {e}")
+
+ status_msg = "限位检测成功" if limit_detected else "超时假设成功"
+ logger.info(f"{axis.name}轴回零完成 ({status_msg}),机械原点: {final_position}步")
+ return True
+
+ except Exception as e:
+ logger.error(f"{axis.name}轴回零失败: {e}")
+ self.emergency_stop(axis)
+ return False
+
+ def home_all_axes(self, sequence: list = None) -> bool:
+ """
+ 全轴回零 (液体处理工作站安全回零)
+
+ 液体处理工作站回零策略:
+ 1. Z轴必须首先回零,避免与容器、试管架等碰撞
+ 2. 然后XY轴回零,确保移动路径安全
+ 3. 严格按照Z->X->Y顺序执行,不允许更改
+
+ Args:
+ sequence: 回零顺序,液体处理工作站固定为Z->X->Y,不建议修改
+
+ Returns:
+ bool: 全轴回零是否成功
+ """
+ if not self.is_connected:
+ logger.error("设备未连接,无法执行回零操作")
+ return False
+
+ # 液体处理工作站安全回零序列:Z轴绝对优先
+ safe_sequence = [MotorAxis.Z, MotorAxis.X, MotorAxis.Y]
+
+ if sequence is not None and sequence != safe_sequence:
+ logger.warning(f"液体处理工作站不建议修改回零序列,使用安全序列: {[axis.name for axis in safe_sequence]}")
+
+ sequence = safe_sequence # 强制使用安全序列
+
+ logger.info("开始全轴回零")
+
+ try:
+ for axis in sequence:
+ if not self.home_axis(axis):
+ logger.error(f"全轴回零失败,停止在{axis.name}轴")
+ return False
+
+ # 轴间等待时间
+ time.sleep(0.5)
+
+ # 标记为已回零
+ self.coordinate_origin.is_homed = True
+ self._save_coordinate_origin()
+
+ logger.info("全轴回零完成")
+ return True
+
+ except Exception as e:
+ logger.error(f"全轴回零异常: {e}")
+ return False
+
+ def set_work_origin_here(self) -> bool:
+ """将当前位置设置为工作原点"""
+ if not self.is_connected:
+ logger.error("设备未连接,无法设置工作原点")
+ return False
+
+ try:
+ if not self.coordinate_origin.is_homed:
+ logger.warning("建议先执行回零操作再设置工作原点")
+
+ # 获取当前各轴位置
+ positions = self.get_all_positions()
+
+ for axis in MotorAxis:
+ axis_name = axis.name.lower()
+ current_steps = positions[axis].steps
+ self.coordinate_origin.work_origin_steps[axis_name] = current_steps
+
+ logger.info(f"{axis.name}轴工作原点设置为: {current_steps}步 "
+ f"({self.steps_to_mm(axis, current_steps):.2f}mm)")
+
+ self._save_coordinate_origin()
+ logger.info("工作原点设置完成")
+ return True
+
+ except Exception as e:
+ logger.error(f"设置工作原点失败: {e}")
+ return False
+
+ # ==================== 高级运动控制方法 ====================
+
+ def move_to_work_coord_safe(self, x: float = None, y: float = None, z: float = None,
+ speed: int = None, acceleration: int = None) -> bool:
+ """
+ 安全移动到工作坐标系指定位置 (液体处理工作站专用)
+ 移动策略:Z轴先上升到安全高度 -> XY轴移动到目标位置 -> Z轴下降到目标位置
+
+ Args:
+ x, y, z: 工作坐标系下的目标位置 (mm)
+ speed: 运动速度 (rpm)
+ acceleration: 加速度 (rpm/s)
+
+ Returns:
+ bool: 移动是否成功
+ """
+ if not self.is_connected:
+ logger.error("设备未连接,无法执行移动操作")
+ return False
+
+ try:
+ # 检查坐标系是否已设置
+ if not self.coordinate_origin.work_origin_steps:
+ raise CoordinateSystemError("工作原点未设置,请先调用set_work_origin_here()")
+
+ # 检查行程限制
+ self.check_travel_limits(x, y, z)
+
+ # 设置运动参数
+ speed = speed or self.machine_config.default_speed
+ acceleration = acceleration or self.machine_config.default_acceleration
+
+ # 步骤1: Z轴先上升到安全高度
+ if z is not None:
+ safe_z_steps = self.work_to_machine_steps(None, None, self.machine_config.safe_z_height)
+ if not self.move_to_position(MotorAxis.Z, safe_z_steps['z'], speed, acceleration):
+ logger.error("Z轴上升到安全高度失败")
+ return False
+ logger.info(f"Z轴上升到安全高度: {self.machine_config.safe_z_height} mm")
+
+ # 等待Z轴移动完成
+ self.wait_for_completion(MotorAxis.Z, 10.0)
+
+ # 步骤2: XY轴移动到目标位置
+ xy_success = True
+ if x is not None:
+ machine_steps = self.work_to_machine_steps(x, None, None)
+ if not self.move_to_position(MotorAxis.X, machine_steps['x'], speed, acceleration):
+ xy_success = False
+
+ if y is not None:
+ machine_steps = self.work_to_machine_steps(None, y, None)
+ if not self.move_to_position(MotorAxis.Y, machine_steps['y'], speed, acceleration):
+ xy_success = False
+
+ if not xy_success:
+ logger.error("XY轴移动失败")
+ return False
+
+ if x is not None or y is not None:
+ logger.info(f"XY轴移动到目标位置: X:{x} Y:{y} mm")
+ # 等待XY轴移动完成
+ if x is not None:
+ self.wait_for_completion(MotorAxis.X, 10.0)
+ if y is not None:
+ self.wait_for_completion(MotorAxis.Y, 10.0)
+
+ # 步骤3: Z轴下降到目标位置
+ if z is not None:
+ machine_steps = self.work_to_machine_steps(None, None, z)
+ if not self.move_to_position(MotorAxis.Z, machine_steps['z'], speed, acceleration):
+ logger.error("Z轴下降到目标位置失败")
+ return False
+ logger.info(f"Z轴下降到目标位置: {z} mm")
+ self.wait_for_completion(MotorAxis.Z, 10.0)
+
+ logger.info(f"安全移动到工作坐标 X:{x} Y:{y} Z:{z} (mm) 完成")
+ return True
+
+ except Exception as e:
+ logger.error(f"安全移动失败: {e}")
+ return False
+
+ def move_to_work_coord(self, x: float = None, y: float = None, z: float = None,
+ speed: int = None, acceleration: int = None) -> bool:
+ """
+ 移动到工作坐标 (已禁用)
+
+ 此方法已被禁用,请使用 move_to_work_coord_safe() 方法。
+
+ Raises:
+ RuntimeError: 方法已禁用
+ """
+ error_msg = "Method disabled, use move_to_work_coord_safe instead"
+ logger.error(error_msg)
+ raise RuntimeError(error_msg)
+
+ def move_relative_work_coord(self, dx: float = 0, dy: float = 0, dz: float = 0,
+ speed: int = None, acceleration: int = None) -> bool:
+ """
+ 相对当前位置移动
+
+ Args:
+ dx, dy, dz: 相对移动距离 (mm)
+ speed: 运动速度 (rpm)
+ acceleration: 加速度 (rpm/s)
+
+ Returns:
+ bool: 移动是否成功
+ """
+ if not self.is_connected:
+ logger.error("设备未连接,无法执行移动操作")
+ return False
+
+ try:
+ # 获取当前工作坐标
+ current_work = self.get_current_work_coords()
+
+ # 计算目标坐标
+ target_x = current_work['x'] + dx if dx != 0 else None
+ target_y = current_work['y'] + dy if dy != 0 else None
+ target_z = current_work['z'] + dz if dz != 0 else None
+
+ return self.move_to_work_coord_safe(target_x, target_y, target_z, speed, acceleration)
+
+ except Exception as e:
+ logger.error(f"相对移动失败: {e}")
+ return False
+
+ def get_current_work_coords(self) -> Dict[str, float]:
+ """获取当前工作坐标"""
+ if not self.is_connected:
+ logger.error("设备未连接,无法获取当前坐标")
+ return {'x': 0.0, 'y': 0.0, 'z': 0.0}
+
+ try:
+ # 获取当前机械坐标
+ positions = self.get_all_positions()
+ machine_steps = {axis.name.lower(): pos.steps for axis, pos in positions.items()}
+
+ # 转换为工作坐标
+ return self.machine_to_work_coords(machine_steps)
+
+ except Exception as e:
+ logger.error(f"获取工作坐标失败: {e}")
+ return {'x': 0.0, 'y': 0.0, 'z': 0.0}
+
+ def get_current_position_mm(self) -> Dict[str, float]:
+ """获取当前位置坐标(毫米单位)"""
+ return self.get_current_work_coords()
+
+ def wait_for_move_completion(self, timeout: float = 30.0) -> bool:
+ """等待所有轴运动完成"""
+ if not self.is_connected:
+ return False
+
+ for axis in MotorAxis:
+ if not self.wait_for_completion(axis, timeout):
+ return False
+ return True
+
+ # ==================== 系统状态和配置方法 ====================
+
+ def get_system_status(self) -> Dict:
+ """获取系统状态信息"""
+ status = {
+ "connection": {
+ "is_connected": self.is_connected,
+ "port": self.port,
+ "baudrate": self.baudrate
+ },
+ "coordinate_system": {
+ "is_homed": self.coordinate_origin.is_homed,
+ "machine_origin": self.coordinate_origin.machine_origin_steps,
+ "work_origin": self.coordinate_origin.work_origin_steps,
+ "timestamp": self.coordinate_origin.timestamp
+ },
+ "machine_config": asdict(self.machine_config),
+ "current_position": {}
+ }
+
+ if self.is_connected:
+ try:
+ # 获取当前位置
+ positions = self.get_all_positions()
+ for axis, pos in positions.items():
+ axis_name = axis.name.lower()
+ status["current_position"][axis_name] = {
+ "steps": pos.steps,
+ "mm": self.steps_to_mm(axis, pos.steps),
+ "status": pos.status.name if hasattr(pos.status, 'name') else str(pos.status)
+ }
+
+ # 获取工作坐标
+ work_coords = self.get_current_work_coords()
+ status["current_work_coords"] = work_coords
+
+ except Exception as e:
+ status["position_error"] = str(e)
+
+ return status
+
+ def update_machine_config(self, **kwargs):
+ """更新机械配置参数"""
+ for key, value in kwargs.items():
+ if hasattr(self.machine_config, key):
+ setattr(self.machine_config, key, value)
+ logger.info(f"更新配置参数 {key}: {value}")
+ else:
+ logger.warning(f"未知配置参数: {key}")
+
+ # 保存配置
+ self._save_config()
+
+ def reset_coordinate_system(self):
+ """重置坐标系统"""
+ self.coordinate_origin = CoordinateOrigin()
+ self._save_coordinate_origin()
+ logger.info("坐标系统已重置")
+
+ def __enter__(self):
+ """上下文管理器入口"""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """上下文管理器出口"""
+ self.disconnect_device()
+
+
+def interactive_control(controller: XYZController):
+ """
+ 交互式控制模式
+
+ Args:
+ controller: 已连接的控制器实例
+ """
+ print("\n" + "="*60)
+ print("进入交互式控制模式")
+ print("="*60)
+
+ # 显示当前状态
+ def show_status():
+ try:
+ current_pos = controller.get_current_position_mm()
+ print(f"\n当前位置: X={current_pos['x']:.2f}mm, Y={current_pos['y']:.2f}mm, Z={current_pos['z']:.2f}mm")
+ except Exception as e:
+ print(f"获取位置失败: {e}")
+
+ # 显示帮助信息
+ def show_help():
+ print("\n可用命令:")
+ print(" move <轴> <距离> - 相对移动,例: move x 10.5")
+ print(" goto - 绝对移动到指定坐标,例: goto 10 20 5")
+ print(" home [轴] - 回零操作,例: home 或 home x")
+ print(" origin - 设置当前位置为工作原点")
+ print(" status - 显示当前状态")
+ print(" speed <速度> - 设置运动速度(rpm),例: speed 2000")
+ print(" limits - 显示行程限制")
+ print(" config - 显示机械配置")
+ print(" help - 显示此帮助信息")
+ print(" quit/exit - 退出交互模式")
+ print("\n提示:")
+ print(" - 轴名称: x, y, z")
+ print(" - 距离单位: 毫米(mm)")
+ print(" - 正数向正方向移动,负数向负方向移动")
+
+ # 安全回零操作
+ def safe_homing():
+ print("\n系统安全初始化...")
+ print("为确保操作安全,系统将执行回零操作")
+ print("提示: 已安装限位开关,超时后将假设回零成功")
+
+ # 询问用户是否继续
+ while True:
+ user_choice = input("是否继续执行回零操作? (y/n/skip): ").strip().lower()
+ if user_choice in ['y', 'yes', '是']:
+ print("\n开始执行全轴回零...")
+ print("回零过程可能需要一些时间,请耐心等待...")
+
+ # 执行回零操作
+ homing_success = controller.home_all_axes()
+
+ if homing_success:
+ print("回零操作完成,系统已就绪")
+ # 设置当前位置为工作原点
+ if controller.set_work_origin_here():
+ print("工作原点已设置为回零位置")
+ else:
+ print("工作原点设置失败,但可以继续操作")
+ return True
+ else:
+ print("回零操作失败")
+ print("这可能是由于通信问题,但限位开关应该已经起作用")
+
+ # 询问是否继续
+ retry_choice = input("是否仍要继续操作? (y/n): ").strip().lower()
+ if retry_choice in ['y', 'yes', '是']:
+ print("继续操作,请手动确认设备位置安全")
+ return True
+ else:
+ return False
+
+ elif user_choice in ['n', 'no', '否']:
+ print("用户取消回零操作,退出交互模式")
+ return False
+ elif user_choice in ['skip', 's', '跳过']:
+ print("跳过回零操作,请注意安全!")
+ print("建议在开始操作前手动执行 'home' 命令")
+ return True
+ else:
+ print("请输入 y(继续)/n(取消)/skip(跳过)")
+
+ # 安全回原点操作
+ def safe_return_home():
+ print("\n系统安全关闭...")
+ print("正在将所有轴移动到安全位置...")
+
+ try:
+ # 移动到工作原点 (0,0,0) - 使用安全移动方法
+ if controller.move_to_work_coord_safe(0, 0, 0, speed=500):
+ print("已安全返回工作原点")
+ show_status()
+ else:
+ print("返回原点失败,请手动检查设备位置")
+ except Exception as e:
+ print(f"返回原点时出错: {e}")
+
+ # 当前运动速度
+ current_speed = controller.machine_config.default_speed
+
+ try:
+ # 1. 首先执行安全回零
+ if not safe_homing():
+ return
+
+ # 2. 显示初始状态和帮助
+ show_status()
+ show_help()
+
+ while True:
+ try:
+ # 获取用户输入
+ user_input = input("\n请输入命令 (输入 help 查看帮助): ").strip().lower()
+
+ if not user_input:
+ continue
+
+ # 解析命令
+ parts = user_input.split()
+ command = parts[0]
+
+ if command in ['quit', 'exit', 'q']:
+ print("准备退出交互模式...")
+ # 执行安全回原点操作
+ safe_return_home()
+ print("退出交互模式")
+ break
+
+ elif command == 'help' or command == 'h':
+ show_help()
+
+ elif command == 'status' or command == 's':
+ show_status()
+ print(f"当前速度: {current_speed} rpm")
+ print(f"是否已回零: {controller.coordinate_origin.is_homed}")
+
+ elif command == 'move' or command == 'm':
+ if len(parts) != 3:
+ print("格式错误,正确格式: move <轴> <距离>")
+ print(" 例如: move x 10.5")
+ continue
+
+ axis = parts[1].lower()
+ try:
+ distance = float(parts[2])
+ except ValueError:
+ print("距离必须是数字")
+ continue
+
+ if axis not in ['x', 'y', 'z']:
+ print("轴名称必须是 x, y 或 z")
+ continue
+
+ print(f"{axis.upper()}轴移动 {distance:+.2f}mm...")
+
+ # 执行移动
+ kwargs = {f'd{axis}': distance, 'speed': current_speed}
+ if controller.move_relative_work_coord(**kwargs):
+ print(f"{axis.upper()}轴移动完成")
+ show_status()
+ else:
+ print(f"{axis.upper()}轴移动失败")
+
+ elif command == 'goto' or command == 'g':
+ if len(parts) != 4:
+ print("格式错误,正确格式: goto ")
+ print(" 例如: goto 10 20 5")
+ continue
+
+ try:
+ x = float(parts[1])
+ y = float(parts[2])
+ z = float(parts[3])
+ except ValueError:
+ print("坐标必须是数字")
+ continue
+
+ print(f"移动到坐标 ({x}, {y}, {z})...")
+ print("使用安全移动策略: Z轴先上升 → XY移动 → Z轴下降")
+
+ if controller.move_to_work_coord_safe(x, y, z, speed=current_speed):
+ print("安全移动到目标位置完成")
+ show_status()
+ else:
+ print("移动失败")
+
+ elif command == 'home':
+ if len(parts) == 1:
+ # 全轴回零
+ print("开始全轴回零...")
+ if controller.home_all_axes():
+ print("全轴回零完成")
+ show_status()
+ else:
+ print("回零失败")
+ elif len(parts) == 2:
+ # 单轴回零
+ axis_name = parts[1].lower()
+ if axis_name not in ['x', 'y', 'z']:
+ print("轴名称必须是 x, y 或 z")
+ continue
+
+ axis = MotorAxis[axis_name.upper()]
+ print(f"{axis_name.upper()}轴回零...")
+
+ if controller.home_axis(axis):
+ print(f"{axis_name.upper()}轴回零完成")
+ show_status()
+ else:
+ print(f"{axis_name.upper()}轴回零失败")
+ else:
+ print("格式错误,正确格式: home 或 home <轴>")
+
+ elif command == 'origin' or command == 'o':
+ print("设置当前位置为工作原点...")
+ if controller.set_work_origin_here():
+ print("工作原点设置完成")
+ show_status()
+ else:
+ print("工作原点设置失败")
+
+ elif command == 'speed':
+ if len(parts) != 2:
+ print("格式错误,正确格式: speed <速度>")
+ print(" 例如: speed 2000")
+ continue
+
+ try:
+ new_speed = int(parts[1])
+ if new_speed <= 0:
+ print("速度必须大于0")
+ continue
+ if new_speed > 10000:
+ print("速度不能超过10000 rpm")
+ continue
+
+ current_speed = new_speed
+ print(f"运动速度设置为: {current_speed} rpm")
+
+ except ValueError:
+ print("速度必须是整数")
+
+ elif command == 'limits' or command == 'l':
+ config = controller.machine_config
+ print("\n行程限制:")
+ print(f" X轴: 0 ~ {config.max_travel_x} mm")
+ print(f" Y轴: 0 ~ {config.max_travel_y} mm")
+ print(f" Z轴: 0 ~ {config.max_travel_z} mm")
+
+ elif command == 'config' or command == 'c':
+ config = controller.machine_config
+ print("\n机械配置:")
+ print(f" X轴步距: {config.steps_per_mm_x:.1f} 步/mm")
+ print(f" Y轴步距: {config.steps_per_mm_y:.1f} 步/mm")
+ print(f" Z轴步距: {config.steps_per_mm_z:.1f} 步/mm")
+ print(f" 回零速度: {config.homing_speed} rpm")
+ print(f" 默认速度: {config.default_speed} rpm")
+ print(f" 安全间隙: {config.safe_clearance} mm")
+
+ else:
+ print(f"未知命令: {command}")
+ print("输入 help 查看可用命令")
+
+ except KeyboardInterrupt:
+ print("\n\n用户中断,退出交互模式")
+ break
+ except Exception as e:
+ print(f"命令执行错误: {e}")
+ print("输入 help 查看正确的命令格式")
+
+ finally:
+ # 确保正确断开连接
+ try:
+ controller.disconnect_device()
+ print("设备连接已断开")
+ except Exception as e:
+ print(f"断开连接时出错: {e}")
+
+
+def run_tests():
+ """运行测试函数"""
+ print("=== XYZ控制器测试 ===")
+
+ # 1. 测试机械配置
+ print("\n1. 测试机械配置")
+ config = MachineConfig(
+ steps_per_mm_x=204.8, # 16384步/圈 ÷ 80mm导程
+ steps_per_mm_y=204.8, # 16384步/圈 ÷ 80mm导程
+ steps_per_mm_z=3276.8, # 16384步/圈 ÷ 5mm导程
+ max_travel_x=340.0,
+ max_travel_y=250.0,
+ max_travel_z=160.0,
+ homing_speed=100,
+ default_speed=100
+ )
+ print(f"X轴步距: {config.steps_per_mm_x} 步/mm")
+ print(f"Y轴步距: {config.steps_per_mm_y} 步/mm")
+ print(f"Z轴步距: {config.steps_per_mm_z} 步/mm")
+ print(f"行程限制: X={config.max_travel_x}mm, Y={config.max_travel_y}mm, Z={config.max_travel_z}mm")
+
+ # 2. 测试坐标原点数据结构
+ print("\n2. 测试坐标原点数据结构")
+ origin = CoordinateOrigin()
+ print(f"初始状态: 已回零={origin.is_homed}")
+ print(f"机械原点: {origin.machine_origin_steps}")
+ print(f"工作原点: {origin.work_origin_steps}")
+
+ # 设置示例数据
+ origin.machine_origin_steps = {'x': 0, 'y': 0, 'z': 0}
+ origin.work_origin_steps = {'x': 16384, 'y': 16384, 'z': 13107} # 5mm, 5mm, 2mm (基于16384步/圈)
+ origin.is_homed = True
+ origin.timestamp = "2024-09-26 12:00:00"
+ print(f"设置后: 已回零={origin.is_homed}")
+ print(f"机械原点: {origin.machine_origin_steps}")
+ print(f"工作原点: {origin.work_origin_steps}")
+
+ # 3. 测试离线功能
+ print("\n3. 测试离线功能")
+
+ # 创建离线控制器(不自动连接)
+ offline_controller = XYZController(
+ port='/dev/tty.usbserial-3130',
+ machine_config=config,
+ auto_connect=False
+ )
+
+ # 测试单位转换
+ print("\n单位转换测试:")
+ test_distances = [1.0, 5.0, 10.0, 25.5]
+ for distance in test_distances:
+ x_steps = offline_controller.mm_to_steps(MotorAxis.X, distance)
+ y_steps = offline_controller.mm_to_steps(MotorAxis.Y, distance)
+ z_steps = offline_controller.mm_to_steps(MotorAxis.Z, distance)
+ print(f"{distance}mm -> X:{x_steps}步, Y:{y_steps}步, Z:{z_steps}步")
+
+ # 反向转换验证
+ x_mm = offline_controller.steps_to_mm(MotorAxis.X, x_steps)
+ y_mm = offline_controller.steps_to_mm(MotorAxis.Y, y_steps)
+ z_mm = offline_controller.steps_to_mm(MotorAxis.Z, z_steps)
+ print(f"反向转换: X:{x_mm:.2f}mm, Y:{y_mm:.2f}mm, Z:{z_mm:.2f}mm")
+
+ # 测试坐标系转换
+ print("\n坐标系转换测试:")
+ offline_controller.coordinate_origin = origin # 使用示例原点
+ work_coords = [(0, 0, 0), (10, 15, 5), (50, 30, 20)]
+
+ for x, y, z in work_coords:
+ try:
+ machine_steps = offline_controller.work_to_machine_steps(x, y, z)
+ print(f"工作坐标 ({x}, {y}, {z}) -> 机械步数 {machine_steps}")
+
+ # 反向转换验证
+ work_coords_back = offline_controller.machine_to_work_coords(machine_steps)
+ print(f"反向转换: ({work_coords_back['x']:.2f}, {work_coords_back['y']:.2f}, {work_coords_back['z']:.2f})")
+ except Exception as e:
+ print(f"转换失败: {e}")
+
+ # 测试行程限制检查
+ print("\n行程限制检查测试:")
+ test_positions = [
+ (50, 50, 25, "正常位置"),
+ (250, 50, 25, "X轴超限"),
+ (50, 350, 25, "Y轴超限"),
+ (50, 50, 150, "Z轴超限"),
+ (-10, 50, 25, "X轴负超限"),
+ (50, -10, 25, "Y轴负超限"),
+ (50, 50, -5, "Z轴负超限")
+ ]
+
+ for x, y, z, desc in test_positions:
+ try:
+ offline_controller.check_travel_limits(x, y, z)
+ print(f"{desc} ({x}, {y}, {z}): 有效")
+ except CoordinateSystemError as e:
+ print(f"{desc} ({x}, {y}, {z}): 超限 - {e}")
+
+ print("\n=== 离线功能测试完成 ===")
+
+ # 4. 硬件连接测试
+ print("\n4. 硬件连接测试")
+ print("尝试连接真实设备...")
+
+ # 可能的串口列表
+ possible_ports = [
+ '/dev/ttyCH341USB0' # CH340 USB串口转换器
+ ]
+
+ connected_controller = None
+
+ for port in possible_ports:
+ try:
+ print(f"尝试连接端口: {port}")
+ controller = XYZController(
+ port=port,
+ machine_config=config,
+ auto_connect=True
+ )
+
+ if controller.is_connected:
+ print(f"成功连接到 {port}")
+ connected_controller = controller
+
+ # 获取系统状态
+ status = controller.get_system_status()
+ print("\n系统状态:")
+ print(f" 连接状态: {status['connection']['is_connected']}")
+ print(f" 是否已回零: {status['coordinate_system']['is_homed']}")
+
+ if 'current_position' in status:
+ print(" 当前位置:")
+ for axis, pos_info in status['current_position'].items():
+ print(f" {axis.upper()}轴: {pos_info['steps']}步 ({pos_info['mm']:.2f}mm)")
+
+ # 测试基本移动功能
+ print("\n测试基本移动功能:")
+ try:
+ # 获取当前位置
+ current_pos = controller.get_current_position_mm()
+ print(f"当前工作坐标: {current_pos}")
+
+ # 小幅移动测试
+ print("执行小幅移动测试 (X+1mm)...")
+ if controller.move_relative_work_coord(dx=1.0, speed=500):
+ print("移动成功")
+ time.sleep(1)
+ new_pos = controller.get_current_position_mm()
+ print(f"移动后坐标: {new_pos}")
+ else:
+ print("移动失败")
+
+ except Exception as e:
+ print(f"移动测试失败: {e}")
+
+ break
+
+ except Exception as e:
+ print(f"连接 {port} 失败: {e}")
+ continue
+
+ if not connected_controller:
+ print("未找到可用的设备端口")
+ print("请检查:")
+ print(" 1. 设备是否正确连接")
+ print(" 2. 串口端口是否正确")
+ print(" 3. 设备驱动是否安装")
+ else:
+ # 进入交互式控制模式
+ interactive_control(connected_controller)
+
+ print("\n=== XYZ控制器测试完成 ===")
+
+
+# ==================== 测试和示例代码 ====================
+if __name__ == "__main__":
+ run_tests()
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/core/__init__.py b/unilabos/devices/laiyu_liquid/core/__init__.py
new file mode 100644
index 00000000..87214f83
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/core/__init__.py
@@ -0,0 +1,44 @@
+#!/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_main import (
+ LaiYuLiquid,
+ LaiYuLiquidConfig,
+ LaiYuLiquidBackend,
+ LaiYuLiquidDeck,
+ LaiYuLiquidContainer,
+ LaiYuLiquidTipRack,
+ create_quick_setup
+)
+
+from .laiyu_liquid_res import (
+ LaiYuLiquidDeck,
+ LaiYuLiquidContainer,
+ LaiYuLiquidTipRack
+)
+
+__all__ = [
+ # 主设备类
+ 'LaiYuLiquid',
+ 'LaiYuLiquidConfig',
+ 'LaiYuLiquidBackend',
+
+ # 设备资源
+ 'LaiYuLiquidDeck',
+ 'LaiYuLiquidContainer',
+ 'LaiYuLiquidTipRack',
+
+ # 工具函数
+ 'create_quick_setup'
+]
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/core/abstract_protocol.py b/unilabos/devices/laiyu_liquid/core/abstract_protocol.py
new file mode 100644
index 00000000..9959c364
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/core/abstract_protocol.py
@@ -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
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py b/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py
new file mode 100644
index 00000000..96092556
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py
@@ -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"
+]
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py b/unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py
new file mode 100644
index 00000000..f6adcb13
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py
@@ -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_main 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
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/docs/CHANGELOG.md b/unilabos/devices/laiyu_liquid/docs/CHANGELOG.md
new file mode 100644
index 00000000..a0f2b632
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/docs/CHANGELOG.md
@@ -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**: 向后兼容的问题修复
+
+### 变更类型
+- **新增功能**: 新的功能特性
+- **变更**: 现有功能的变更
+- **弃用**: 即将移除的功能
+- **移除**: 已移除的功能
+- **修复**: 问题修复
+- **安全**: 安全相关的修复
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/docs/hardware/SOPA气动式移液器RS485控制指令.md b/unilabos/devices/laiyu_liquid/docs/hardware/SOPA气动式移液器RS485控制指令.md
new file mode 100644
index 00000000..6db19eb1
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/docs/hardware/SOPA气动式移液器RS485控制指令.md
@@ -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
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md b/unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md
new file mode 100644
index 00000000..e7013484
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md
@@ -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反向
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md b/unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md
new file mode 100644
index 00000000..64529097
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md
@@ -0,0 +1,1281 @@
+# LaiYu液体处理设备硬件连接配置指南
+
+## 📋 文档概述
+
+本指南提供LaiYu液体处理设备的完整硬件连接配置方案,包括快速入门、详细配置、连接验证和故障排除。适用于设备初次安装、配置变更和问题诊断。
+
+---
+
+## 🚀 快速入门指南
+
+### 基本配置步骤
+
+1. **确认硬件连接**
+ - 将RS485转USB设备连接到计算机
+ - 确保XYZ控制器和移液器通过RS485总线连接
+ - 检查设备供电状态
+
+2. **获取串口信息**
+ ```bash
+ # macOS/Linux
+ ls /dev/cu.* | grep usbserial
+
+ # 常见输出: /dev/cu.usbserial-3130
+ ```
+
+3. **基本配置参数**
+ ```python
+ # 推荐的默认配置
+ config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130", # 🔧 替换为实际串口号
+ address=4, # 移液器地址(固定)
+ baudrate=115200, # 推荐波特率
+ timeout=5.0 # 通信超时
+ )
+ ```
+
+4. **快速连接测试**
+ ```python
+ device = LaiYuLiquid(config)
+ success = await device.setup()
+ print(f"连接状态: {'成功' if success else '失败'}")
+ ```
+
+---
+
+## 🏗️ 硬件架构详解
+
+### 系统组成
+
+LaiYu液体处理设备采用RS485总线架构,包含以下核心组件:
+
+| 组件 | 通信协议 | 设备地址 | 默认波特率 | 功能描述 |
+|------|----------|----------|------------|----------|
+| **XYZ三轴控制器** | RS485 (Modbus) | X轴=1, Y轴=2, Z轴=3 | 115200 | 三维运动控制 |
+| **SOPA移液器** | RS485 | 4 (推荐) | 115200 | 液体吸取分配 |
+| **RS485转USB** | USB/串口 | - | 115200 | 通信接口转换 |
+
+### 地址分配策略
+
+```
+RS485总线地址分配:
+├── 地址 1: X轴步进电机 (自动分配)
+├── 地址 2: Y轴步进电机 (自动分配)
+├── 地址 3: Z轴步进电机 (自动分配)
+├── 地址 4: SOPA移液器 (推荐配置)
+└── 禁用地址: 47('/'), 69('E'), 91('[')
+```
+
+### 通信参数规范
+
+| 参数 | XYZ控制器 | SOPA移液器 | 说明 |
+|------|-----------|------------|------|
+| **数据位** | 8 | 8 | 固定值 |
+| **停止位** | 1 | 1 | 固定值 |
+| **校验位** | 无 | 无 | 固定值 |
+| **流控制** | 无 | 无 | 固定值 |
+
+---
+
+## ⚙️ 配置参数详解
+
+### 1. 核心配置类
+
+#### LaiYuLiquidConfig 参数说明
+
+```python
+@dataclass
+class LaiYuLiquidConfig:
+ # === 通信参数 ===
+ port: str = "/dev/cu.usbserial-3130" # 串口设备路径
+ address: int = 4 # 移液器地址(推荐值)
+ baudrate: int = 115200 # 通信波特率(推荐值)
+ timeout: float = 5.0 # 通信超时时间(秒)
+
+ # === 工作台物理尺寸 ===
+ deck_width: float = 340.0 # 工作台宽度 (mm)
+ deck_height: float = 250.0 # 工作台高度 (mm)
+ deck_depth: float = 160.0 # 工作台深度 (mm)
+
+ # === 运动控制参数 ===
+ max_speed: float = 100.0 # 最大移动速度 (mm/s)
+ acceleration: float = 50.0 # 加速度 (mm/s²)
+ safe_height: float = 50.0 # 安全移动高度 (mm)
+
+ # === 移液参数 ===
+ max_volume: float = 1000.0 # 最大移液体积 (μL)
+ min_volume: float = 0.1 # 最小移液体积 (μL)
+ liquid_detection: bool = True # 启用液面检测
+
+ # === 枪头操作参数 ===
+ tip_pickup_speed: int = 30 # 取枪头速度 (rpm)
+ tip_pickup_acceleration: int = 500 # 取枪头加速度 (rpm/s)
+ tip_pickup_depth: float = 10.0 # 枪头插入深度 (mm)
+ tip_drop_height: float = 10.0 # 丢弃枪头高度 (mm)
+```
+
+### 2. 配置文件位置
+
+#### A. 代码配置(推荐)
+```python
+# 在Python代码中直接配置
+from unilabos.devices.laiyu_liquid import LaiYuLiquidConfig
+
+config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130", # 🔧 修改为实际串口
+ address=4, # 🔧 移液器地址
+ baudrate=115200, # 🔧 通信波特率
+ timeout=5.0 # 🔧 超时时间
+)
+```
+
+#### B. JSON配置文件
+```json
+{
+ "laiyu_liquid_config": {
+ "port": "/dev/cu.usbserial-3130",
+ "address": 4,
+ "baudrate": 115200,
+ "timeout": 5.0,
+ "deck_width": 340.0,
+ "deck_height": 250.0,
+ "deck_depth": 160.0,
+ "max_speed": 100.0,
+ "acceleration": 50.0,
+ "safe_height": 50.0
+ }
+}
+```
+
+#### C. 实验协议配置
+```json
+// test/experiments/laiyu_liquid.json
+{
+ "device_config": {
+ "type": "laiyu_liquid",
+ "config": {
+ "port": "/dev/cu.usbserial-3130",
+ "address": 4,
+ "baudrate": 115200
+ }
+ }
+}
+```
+
+### 2. 串口设备识别
+
+#### 自动识别方法(推荐)
+
+```python
+import serial.tools.list_ports
+
+def find_laiyu_device():
+ """自动查找LaiYu设备串口"""
+ ports = serial.tools.list_ports.comports()
+
+ for port in ports:
+ # 根据设备描述或VID/PID识别
+ if 'usbserial' in port.device.lower():
+ print(f"找到可能的设备: {port.device}")
+ print(f"描述: {port.description}")
+ print(f"硬件ID: {port.hwid}")
+ return port.device
+
+ return None
+
+# 使用示例
+device_port = find_laiyu_device()
+if device_port:
+ print(f"检测到设备端口: {device_port}")
+else:
+ print("未检测到设备")
+```
+
+#### 手动识别方法
+
+| 操作系统 | 命令 | 设备路径格式 |
+|---------|------|-------------|
+| **macOS** | `ls /dev/cu.*` | `/dev/cu.usbserial-XXXX` |
+| **Linux** | `ls /dev/ttyUSB*` | `/dev/ttyUSB0` |
+| **Windows** | 设备管理器 | `COM3`, `COM4` 等 |
+
+#### macOS 详细识别
+```bash
+# 1. 列出所有USB串口设备
+ls /dev/cu.usbserial-*
+
+# 2. 查看USB设备详细信息
+system_profiler SPUSBDataType | grep -A 10 "Serial"
+
+# 3. 实时监控设备插拔
+ls /dev/cu.* && echo "--- 请插入设备 ---" && sleep 3 && ls /dev/cu.*
+```
+
+#### Linux 详细识别
+```bash
+# 1. 列出串口设备
+ls /dev/ttyUSB* /dev/ttyACM*
+
+# 2. 查看设备信息
+dmesg | grep -i "usb.*serial"
+lsusb | grep -i "serial\|converter"
+
+# 3. 查看设备属性
+udevadm info --name=/dev/ttyUSB0 --attribute-walk
+```
+
+#### Windows 详细识别
+```powershell
+# PowerShell命令
+Get-WmiObject -Class Win32_SerialPort | Select-Object Name, DeviceID, Description
+
+# 或在设备管理器中查看"端口(COM和LPT)"
+```
+
+### 3. 控制器特定配置
+
+#### XYZ步进电机控制器
+- **地址范围**: 1-3 (X轴=1, Y轴=2, Z轴=3)
+- **通信协议**: Modbus RTU
+- **波特率**: 9600 或 115200
+- **数据位**: 8
+- **停止位**: 1
+- **校验位**: None
+
+#### XYZ控制器配置 (`controllers/xyz_controller.py`)
+
+XYZ控制器负责三轴运动控制,提供精确的位置控制和运动规划功能。
+
+**主要功能:**
+- 三轴独立控制(X、Y、Z轴)
+- 位置精度控制
+- 运动速度调节
+- 安全限位检测
+
+**配置参数:**
+```python
+xyz_config = {
+ "port": "/dev/ttyUSB0", # 串口设备
+ "baudrate": 115200, # 波特率
+ "timeout": 1.0, # 通信超时
+ "max_speed": { # 最大速度限制
+ "x": 1000, # X轴最大速度
+ "y": 1000, # Y轴最大速度
+ "z": 500 # Z轴最大速度
+ },
+ "acceleration": 500, # 加速度
+ "home_position": [0, 0, 0] # 原点位置
+}
+```
+
+```python
+def __init__(self, port: str, baudrate: int = 115200,
+ machine_config: Optional[MachineConfig] = None,
+ config_file: str = "machine_config.json",
+ auto_connect: bool = True):
+ """
+ Args:
+ port: 串口端口 (如: "/dev/cu.usbserial-3130")
+ baudrate: 波特率 (默认: 115200)
+ machine_config: 机械配置参数
+ config_file: 配置文件路径
+ auto_connect: 是否自动连接
+ """
+```
+
+#### SOPA移液器
+- **地址**: 通常为 4 或更高
+- **通信协议**: 自定义协议
+- **波特率**: 115200 (推荐)
+- **响应时间**: < 100ms
+
+#### 移液器控制器配置 (`controllers/pipette_controller.py`)
+
+移液器控制器负责精确的液体吸取和分配操作,支持多种移液模式和参数配置。
+
+**主要功能:**
+- 精确体积控制
+- 液面检测
+- 枪头管理
+- 速度调节
+
+**配置参数:**
+```python
+@dataclass
+class SOPAConfig:
+ # 通信参数
+ port: str = "/dev/ttyUSB0" # 🔧 修改串口号
+ baudrate: int = 115200 # 🔧 修改波特率
+ address: int = 1 # 🔧 修改设备地址 (1-254)
+ timeout: float = 5.0 # 🔧 修改超时时间
+ comm_type: CommunicationType = CommunicationType.TERMINAL_DEBUG
+```
+
+## 🔍 连接验证与测试
+
+### 1. 编程方式验证连接
+
+#### 创建测试脚本
+```python
+#!/usr/bin/env python3
+"""
+LaiYu液体处理设备连接测试脚本
+"""
+
+import sys
+import os
+sys.path.append('/Users/dp/Documents/DPT/HuaiRou/Uni-Lab-OS')
+
+from unilabos.devices.laiyu_liquid.core.LaiYu_Liquid import (
+ LaiYuLiquid, LaiYuLiquidConfig
+)
+
+def test_connection():
+ """测试设备连接"""
+
+ # 🔧 修改这里的配置参数
+ config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130", # 修改为你的串口号
+ address=1, # 修改为你的设备地址
+ baudrate=9600, # 修改为你的波特率
+ timeout=5.0
+ )
+
+ print("🔌 正在测试LaiYu液体处理设备连接...")
+ print(f"串口: {config.port}")
+ print(f"波特率: {config.baudrate}")
+ print(f"设备地址: {config.address}")
+ print("-" * 50)
+
+ try:
+ # 创建设备实例
+ device = LaiYuLiquid(config)
+
+ # 尝试连接和初始化
+ print("📡 正在连接设备...")
+ success = await device.setup()
+
+ if success:
+ print("✅ 设备连接成功!")
+ print(f"连接状态: {device.is_connected}")
+ print(f"初始化状态: {device.is_initialized}")
+ print(f"当前位置: {device.current_position}")
+
+ # 获取设备状态
+ status = device.get_status()
+ print("\n📊 设备状态:")
+ for key, value in status.items():
+ print(f" {key}: {value}")
+
+ else:
+ print("❌ 设备连接失败!")
+ print("请检查:")
+ print(" 1. 串口号是否正确")
+ print(" 2. 设备是否已连接并通电")
+ print(" 3. 波特率和设备地址是否匹配")
+ print(" 4. 串口是否被其他程序占用")
+
+ except Exception as e:
+ print(f"❌ 连接测试出错: {e}")
+ print("\n🔧 故障排除建议:")
+ print(" 1. 检查串口设备是否存在:")
+ print(" macOS: ls /dev/cu.*")
+ print(" Linux: ls /dev/ttyUSB* /dev/ttyACM*")
+ print(" 2. 检查设备权限:")
+ print(" sudo chmod 666 /dev/cu.usbserial-*")
+ print(" 3. 检查设备是否被占用:")
+ print(" lsof | grep /dev/cu.usbserial")
+
+ finally:
+ # 清理连接
+ if 'device' in locals():
+ await device.stop()
+
+if __name__ == "__main__":
+ import asyncio
+ asyncio.run(test_connection())
+```
+
+### 2. 命令行验证工具
+
+#### 串口通信测试
+```bash
+# 安装串口调试工具
+pip install pyserial
+
+# 使用Python测试串口
+python -c "
+import serial
+try:
+ ser = serial.Serial('/dev/cu.usbserial-3130', 9600, timeout=1)
+ print('串口连接成功:', ser.is_open)
+ ser.close()
+except Exception as e:
+ print('串口连接失败:', e)
+"
+```
+
+#### 设备权限检查
+```bash
+# macOS/Linux 检查串口权限
+ls -la /dev/cu.usbserial-*
+
+# 如果权限不足,修改权限
+sudo chmod 666 /dev/cu.usbserial-*
+
+# 检查串口是否被占用
+lsof | grep /dev/cu.usbserial
+```
+
+### 3. 连接状态指示器
+
+设备提供多种方式检查连接状态:
+
+#### A. 属性检查
+```python
+device = LaiYuLiquid(config)
+
+# 检查连接状态
+print(f"设备已连接: {device.is_connected}")
+print(f"设备已初始化: {device.is_initialized}")
+print(f"枪头已安装: {device.tip_attached}")
+print(f"当前位置: {device.current_position}")
+print(f"当前体积: {device.current_volume}")
+```
+
+#### B. 状态字典
+```python
+status = device.get_status()
+print("完整设备状态:", status)
+
+# 输出示例:
+# {
+# 'connected': True,
+# 'initialized': True,
+# 'position': (0.0, 0.0, 50.0),
+# 'tip_attached': False,
+# 'current_volume': 0.0,
+# 'last_error': None
+# }
+```
+
+## 🛠️ 故障排除指南
+
+### 1. 连接问题诊断
+
+#### 🔍 问题诊断流程
+```python
+def diagnose_connection_issues():
+ """连接问题诊断工具"""
+ import serial.tools.list_ports
+ import serial
+
+ print("🔍 开始连接问题诊断...")
+
+ # 1. 检查串口设备
+ ports = list(serial.tools.list_ports.comports())
+ if not ports:
+ print("❌ 未检测到任何串口设备")
+ print("💡 解决方案:")
+ print(" - 检查USB连接线")
+ print(" - 确认设备电源")
+ print(" - 安装设备驱动")
+ return
+
+ print(f"✅ 检测到 {len(ports)} 个串口设备")
+ for port in ports:
+ print(f" 📍 {port.device}: {port.description}")
+
+ # 2. 测试串口访问权限
+ for port in ports:
+ try:
+ with serial.Serial(port.device, 9600, timeout=1):
+ print(f"✅ {port.device}: 访问权限正常")
+ except PermissionError:
+ print(f"❌ {port.device}: 权限不足")
+ print("💡 解决方案: sudo chmod 666 " + port.device)
+ except Exception as e:
+ print(f"⚠️ {port.device}: {e}")
+
+# 运行诊断
+diagnose_connection_issues()
+```
+
+#### 🚫 常见连接错误
+
+| 错误类型 | 症状 | 解决方案 |
+|---------|------|----------|
+| **设备未找到** | `FileNotFoundError: No such file or directory` | 1. 检查USB连接
2. 确认设备驱动
3. 重新插拔设备 |
+| **权限不足** | `PermissionError: Permission denied` | 1. `sudo chmod 666 /dev/ttyUSB0`
2. 添加用户到dialout组
3. 使用sudo运行 |
+| **设备占用** | `SerialException: Device or resource busy` | 1. 关闭其他程序
2. `lsof /dev/ttyUSB0`查找占用
3. 重启系统 |
+| **驱动问题** | 设备管理器显示未知设备 | 1. 安装CH340/CP210x驱动
2. 更新系统驱动
3. 使用原装USB线 |
+
+### 2. 通信问题解决
+
+#### 📡 通信参数调试
+```python
+def test_communication_parameters():
+ """测试不同通信参数"""
+ import serial
+
+ port = "/dev/cu.usbserial-3130" # 修改为实际端口
+ baudrates = [9600, 19200, 38400, 57600, 115200]
+
+ for baudrate in baudrates:
+ print(f"🔄 测试波特率: {baudrate}")
+ try:
+ with serial.Serial(port, baudrate, timeout=2) as ser:
+ # 发送测试命令
+ test_cmd = b'\x01\x03\x00\x00\x00\x01\x84\x0A'
+ ser.write(test_cmd)
+
+ response = ser.read(100)
+ if response:
+ print(f" ✅ 成功: 收到 {len(response)} 字节")
+ print(f" 📦 数据: {response.hex()}")
+ return baudrate
+ else:
+ print(f" ❌ 无响应")
+ except Exception as e:
+ print(f" ❌ 错误: {e}")
+
+ return None
+```
+
+#### ⚡ 通信故障排除
+
+| 问题类型 | 症状 | 诊断方法 | 解决方案 |
+|---------|------|----------|----------|
+| **通信超时** | `TimeoutError` | 检查波特率和设备地址 | 1. 调整超时时间
2. 验证波特率
3. 检查设备地址 |
+| **数据校验错误** | `CRCError` | 检查数据完整性 | 1. 更换USB线
2. 降低波特率
3. 检查电磁干扰 |
+| **协议错误** | 响应格式异常 | 验证命令格式 | 1. 检查协议版本
2. 确认设备类型
3. 更新固件 |
+| **间歇性故障** | 时好时坏 | 监控连接稳定性 | 1. 检查连接线
2. 稳定电源
3. 减少干扰源 |
+
+### 3. 设备功能问题
+
+#### 🎯 设备状态检查
+```python
+def check_device_health():
+ """设备健康状态检查"""
+ from unilabos.devices.laiyu_liquid import LaiYuLiquidConfig, LaiYuLiquidBackend
+
+ config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130",
+ address=4,
+ baudrate=115200,
+ timeout=5.0
+ )
+
+ try:
+ backend = LaiYuLiquidBackend(config)
+ backend.connect()
+
+ # 检查项目
+ checks = {
+ "设备连接": lambda: backend.is_connected(),
+ "XYZ轴状态": lambda: backend.xyz_controller.get_all_positions(),
+ "移液器状态": lambda: backend.pipette_controller.get_status(),
+ "设备温度": lambda: backend.get_temperature(),
+ "错误状态": lambda: backend.get_error_status(),
+ }
+
+ print("🏥 设备健康检查报告")
+ print("=" * 40)
+
+ for check_name, check_func in checks.items():
+ try:
+ result = check_func()
+ print(f"✅ {check_name}: 正常")
+ if result:
+ print(f" 📊 数据: {result}")
+ except Exception as e:
+ print(f"❌ {check_name}: 异常 - {e}")
+
+ backend.disconnect()
+
+ except Exception as e:
+ print(f"❌ 无法连接设备: {e}")
+```
+
+### 4. 高级故障排除
+
+#### 🔧 日志分析工具
+```python
+import logging
+
+def setup_debug_logging():
+ """设置调试日志"""
+ logging.basicConfig(
+ level=logging.DEBUG,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.FileHandler('laiyu_debug.log'),
+ logging.StreamHandler()
+ ]
+ )
+
+ # 启用串口通信日志
+ serial_logger = logging.getLogger('serial')
+ serial_logger.setLevel(logging.DEBUG)
+
+ print("🔍 调试日志已启用,日志文件: laiyu_debug.log")
+```
+
+#### 📊 性能监控
+```python
+def monitor_performance():
+ """性能监控工具"""
+ import time
+ import psutil
+
+ print("📊 开始性能监控...")
+
+ start_time = time.time()
+ start_cpu = psutil.cpu_percent()
+ start_memory = psutil.virtual_memory().percent
+
+ # 执行设备操作
+ # ... 你的设备操作代码 ...
+
+ end_time = time.time()
+ end_cpu = psutil.cpu_percent()
+ end_memory = psutil.virtual_memory().percent
+
+ print(f"⏱️ 执行时间: {end_time - start_time:.2f} 秒")
+ print(f"💻 CPU使用: {end_cpu - start_cpu:.1f}%")
+ print(f"🧠 内存使用: {end_memory - start_memory:.1f}%")
+```
+
+## 📝 配置文件模板
+
+### 1. 基础配置模板
+
+#### 标准配置(推荐)
+```python
+from unilabos.devices.laiyu_liquid import LaiYuLiquidConfig, LaiYuLiquidBackend, LaiYuLiquid
+
+# 创建标准配置
+config = LaiYuLiquidConfig(
+ # === 通信参数 ===
+ port="/dev/cu.usbserial-3130", # 🔧 修改为实际串口
+ address=4, # 移液器地址(推荐)
+ baudrate=115200, # 通信波特率(推荐)
+ timeout=5.0, # 通信超时时间
+
+ # === 工作台尺寸 ===
+ deck_width=340.0, # 工作台宽度 (mm)
+ deck_height=250.0, # 工作台高度 (mm)
+ deck_depth=160.0, # 工作台深度 (mm)
+
+ # === 运动控制参数 ===
+ max_speed=100.0, # 最大移动速度 (mm/s)
+ acceleration=50.0, # 加速度 (mm/s²)
+ safe_height=50.0, # 安全移动高度 (mm)
+
+ # === 移液参数 ===
+ max_volume=1000.0, # 最大移液体积 (μL)
+ min_volume=0.1, # 最小移液体积 (μL)
+ liquid_detection=True, # 启用液面检测
+
+ # === 枪头操作参数 ===
+ tip_pickup_speed=30, # 取枪头速度 (rpm)
+ tip_pickup_acceleration=500, # 取枪头加速度 (rpm/s)
+ tip_pickup_depth=10.0, # 枪头插入深度 (mm)
+ tip_drop_height=10.0, # 丢弃枪头高度 (mm)
+)
+
+# 创建设备实例
+backend = LaiYuLiquidBackend(config)
+device = LaiYuLiquid(backend)
+```
+
+### 2. 高级配置模板
+
+#### 多设备配置
+```python
+# 配置多个LaiYu设备
+configs = {
+ "device_1": LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130",
+ address=4,
+ baudrate=115200,
+ deck_width=340.0,
+ deck_height=250.0,
+ deck_depth=160.0
+ ),
+ "device_2": LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3131",
+ address=4,
+ baudrate=115200,
+ deck_width=340.0,
+ deck_height=250.0,
+ deck_depth=160.0
+ )
+}
+
+# 创建设备实例
+devices = {}
+for name, config in configs.items():
+ backend = LaiYuLiquidBackend(config)
+ devices[name] = LaiYuLiquid(backend)
+```
+
+#### 自定义参数配置
+```python
+# 高精度移液配置
+precision_config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130",
+ address=4,
+ baudrate=115200,
+ timeout=10.0, # 增加超时时间
+
+ # 精密运动控制
+ max_speed=50.0, # 降低速度提高精度
+ acceleration=25.0, # 降低加速度
+ safe_height=30.0, # 降低安全高度
+
+ # 精密移液参数
+ max_volume=200.0, # 小体积移液
+ min_volume=0.5, # 提高最小体积
+ liquid_detection=True,
+
+ # 精密枪头操作
+ tip_pickup_speed=15, # 降低取枪头速度
+ tip_pickup_acceleration=250, # 降低加速度
+ tip_pickup_depth=8.0, # 减少插入深度
+ tip_drop_height=5.0, # 降低丢弃高度
+)
+```
+
+### 3. 实验协议配置
+
+#### JSON配置文件模板
+```json
+{
+ "experiment_name": "LaiYu液体处理实验",
+ "version": "1.0",
+ "devices": {
+ "laiyu_liquid": {
+ "type": "LaiYu_Liquid",
+ "config": {
+ "port": "/dev/cu.usbserial-3130",
+ "address": 4,
+ "baudrate": 115200,
+ "timeout": 5.0,
+ "deck_width": 340.0,
+ "deck_height": 250.0,
+ "deck_depth": 160.0,
+ "max_speed": 100.0,
+ "acceleration": 50.0,
+ "safe_height": 50.0,
+ "max_volume": 1000.0,
+ "min_volume": 0.1,
+ "liquid_detection": true
+ }
+ }
+ },
+ "deck_layout": {
+ "tip_rack": {
+ "type": "tip_rack_96",
+ "position": [10, 10, 0],
+ "tips": "1000μL"
+ },
+ "source_plate": {
+ "type": "plate_96",
+ "position": [100, 10, 0],
+ "contents": "样品"
+ },
+ "dest_plate": {
+ "type": "plate_96",
+ "position": [200, 10, 0],
+ "contents": "目标"
+ }
+ }
+}
+```
+
+### 4. 完整配置示例
+```json
+{
+ "laiyu_liquid_config": {
+ "communication": {
+ "xyz_controller": {
+ "port": "/dev/cu.usbserial-3130",
+ "baudrate": 115200,
+ "timeout": 5.0
+ },
+ "pipette_controller": {
+ "port": "/dev/cu.usbserial-3131",
+ "baudrate": 115200,
+ "address": 4,
+ "timeout": 5.0
+ }
+ },
+ "mechanical": {
+ "deck_width": 340.0,
+ "deck_height": 250.0,
+ "deck_depth": 160.0,
+ "safe_height": 50.0
+ },
+ "motion": {
+ "max_speed": 100.0,
+ "acceleration": 50.0,
+ "tip_pickup_speed": 30,
+ "tip_pickup_acceleration": 500
+ },
+ "safety": {
+ "position_validation": true,
+ "emergency_stop_enabled": true,
+ "deck_width": 300.0,
+ "deck_height": 200.0,
+ "deck_depth": 100.0,
+ "safe_height": 50.0
+ }
+ }
+}
+```
+
+### 5. 完整使用示例
+
+#### 基础移液操作
+```python
+async def basic_pipetting_example():
+ """基础移液操作示例"""
+
+ # 1. 设备初始化
+ config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130",
+ address=4,
+ baudrate=115200
+ )
+
+ backend = LaiYuLiquidBackend(config)
+ device = LaiYuLiquid(backend)
+
+ try:
+ # 2. 设备设置
+ await device.setup()
+ print("✅ 设备初始化完成")
+
+ # 3. 回到原点
+ await device.home_all_axes()
+ print("✅ 轴归零完成")
+
+ # 4. 取枪头
+ tip_position = (50, 50, 10) # 枪头架位置
+ await device.pick_up_tip(tip_position)
+ print("✅ 取枪头完成")
+
+ # 5. 移液操作
+ source_pos = (100, 100, 15) # 源位置
+ dest_pos = (200, 200, 15) # 目标位置
+ volume = 100.0 # 移液体积 (μL)
+
+ await device.aspirate(volume, source_pos)
+ print(f"✅ 吸取 {volume}μL 完成")
+
+ await device.dispense(volume, dest_pos)
+ print(f"✅ 分配 {volume}μL 完成")
+
+ # 6. 丢弃枪头
+ trash_position = (300, 300, 20)
+ await device.drop_tip(trash_position)
+ print("✅ 丢弃枪头完成")
+
+ except Exception as e:
+ print(f"❌ 操作失败: {e}")
+
+ finally:
+ # 7. 清理资源
+ await device.cleanup()
+ print("✅ 设备清理完成")
+
+# 运行示例
+import asyncio
+asyncio.run(basic_pipetting_example())
+```
+
+#### 批量处理示例
+```python
+async def batch_processing_example():
+ """批量处理示例"""
+
+ config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130",
+ address=4,
+ baudrate=115200
+ )
+
+ backend = LaiYuLiquidBackend(config)
+ device = LaiYuLiquid(backend)
+
+ try:
+ await device.setup()
+ await device.home_all_axes()
+
+ # 定义位置
+ tip_rack = [(50 + i*9, 50, 10) for i in range(12)] # 12个枪头位置
+ source_wells = [(100 + i*9, 100, 15) for i in range(12)] # 12个源孔
+ dest_wells = [(200 + i*9, 200, 15) for i in range(12)] # 12个目标孔
+
+ # 批量移液
+ for i in range(12):
+ print(f"🔄 处理第 {i+1} 个样品...")
+
+ # 取枪头
+ await device.pick_up_tip(tip_rack[i])
+
+ # 移液
+ await device.aspirate(50.0, source_wells[i])
+ await device.dispense(50.0, dest_wells[i])
+
+ # 丢弃枪头
+ await device.drop_tip((300, 300, 20))
+
+ print(f"✅ 第 {i+1} 个样品处理完成")
+
+ print("🎉 批量处理完成!")
+
+ except Exception as e:
+ print(f"❌ 批量处理失败: {e}")
+
+ finally:
+ await device.cleanup()
+
+# 运行批量处理
+asyncio.run(batch_processing_example())
+```
+
+## 🔧 调试与日志管理
+
+### 1. 调试模式配置
+
+#### 启用全局调试
+```python
+import logging
+from unilabos.devices.laiyu_liquid import LaiYuLiquidConfig, LaiYuLiquidBackend
+
+# 配置全局日志
+logging.basicConfig(
+ level=logging.DEBUG,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.FileHandler('laiyu_debug.log'),
+ logging.StreamHandler()
+ ]
+)
+
+# 创建调试配置
+debug_config = LaiYuLiquidConfig(
+ port="/dev/cu.usbserial-3130",
+ address=4,
+ baudrate=115200,
+ timeout=10.0, # 增加超时时间便于调试
+ debug_mode=True # 启用调试模式
+)
+```
+
+#### 分级日志配置
+```python
+def setup_logging(log_level="INFO"):
+ """设置分级日志"""
+
+ # 日志级别映射
+ levels = {
+ "DEBUG": logging.DEBUG,
+ "INFO": logging.INFO,
+ "WARNING": logging.WARNING,
+ "ERROR": logging.ERROR
+ }
+
+ # 创建日志记录器
+ logger = logging.getLogger('LaiYu_Liquid')
+ logger.setLevel(levels.get(log_level, logging.INFO))
+
+ # 文件处理器
+ file_handler = logging.FileHandler('laiyu_operations.log')
+ file_formatter = logging.Formatter(
+ '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
+ )
+ file_handler.setFormatter(file_formatter)
+
+ # 控制台处理器
+ console_handler = logging.StreamHandler()
+ console_formatter = logging.Formatter('%(levelname)s - %(message)s')
+ console_handler.setFormatter(console_formatter)
+
+ logger.addHandler(file_handler)
+ logger.addHandler(console_handler)
+
+ return logger
+
+# 使用示例
+logger = setup_logging("DEBUG")
+logger.info("开始LaiYu设备操作")
+```
+
+### 2. 通信监控
+
+#### 串口通信日志
+```python
+def enable_serial_logging():
+ """启用串口通信日志"""
+ import serial
+
+ # 创建串口日志记录器
+ serial_logger = logging.getLogger('serial.communication')
+ serial_logger.setLevel(logging.DEBUG)
+
+ # 创建专用的串口日志文件
+ serial_handler = logging.FileHandler('laiyu_serial.log')
+ serial_formatter = logging.Formatter(
+ '%(asctime)s - SERIAL - %(message)s'
+ )
+ serial_handler.setFormatter(serial_formatter)
+ serial_logger.addHandler(serial_handler)
+
+ print("📡 串口通信日志已启用: laiyu_serial.log")
+ return serial_logger
+```
+
+#### 实时通信监控
+```python
+class CommunicationMonitor:
+ """通信监控器"""
+
+ def __init__(self):
+ self.sent_count = 0
+ self.received_count = 0
+ self.error_count = 0
+ self.start_time = time.time()
+
+ def log_sent(self, data):
+ """记录发送数据"""
+ self.sent_count += 1
+ logging.debug(f"📤 发送 #{self.sent_count}: {data.hex()}")
+
+ def log_received(self, data):
+ """记录接收数据"""
+ self.received_count += 1
+ logging.debug(f"📥 接收 #{self.received_count}: {data.hex()}")
+
+ def log_error(self, error):
+ """记录错误"""
+ self.error_count += 1
+ logging.error(f"❌ 通信错误 #{self.error_count}: {error}")
+
+ def get_statistics(self):
+ """获取统计信息"""
+ duration = time.time() - self.start_time
+ return {
+ "运行时间": f"{duration:.2f}秒",
+ "发送次数": self.sent_count,
+ "接收次数": self.received_count,
+ "错误次数": self.error_count,
+ "成功率": f"{((self.sent_count - self.error_count) / max(self.sent_count, 1) * 100):.1f}%"
+ }
+```
+
+### 3. 性能监控
+
+#### 操作性能分析
+```python
+import time
+import functools
+
+def performance_monitor(operation_name):
+ """性能监控装饰器"""
+ def decorator(func):
+ @functools.wraps(func)
+ async def wrapper(*args, **kwargs):
+ start_time = time.time()
+ start_memory = psutil.Process().memory_info().rss / 1024 / 1024 # MB
+
+ try:
+ result = await func(*args, **kwargs)
+
+ end_time = time.time()
+ end_memory = psutil.Process().memory_info().rss / 1024 / 1024 # MB
+
+ duration = end_time - start_time
+ memory_delta = end_memory - start_memory
+
+ logging.info(f"⏱️ {operation_name}: {duration:.3f}s, 内存变化: {memory_delta:+.1f}MB")
+
+ return result
+
+ except Exception as e:
+ end_time = time.time()
+ duration = end_time - start_time
+ logging.error(f"❌ {operation_name} 失败 ({duration:.3f}s): {e}")
+ raise
+
+ return wrapper
+ return decorator
+
+# 使用示例
+@performance_monitor("移液操作")
+async def monitored_pipetting():
+ await device.aspirate(100.0, (100, 100, 15))
+ await device.dispense(100.0, (200, 200, 15))
+```
+
+#### 系统资源监控
+```python
+import psutil
+import threading
+import time
+
+class SystemMonitor:
+ """系统资源监控器"""
+
+ def __init__(self, interval=1.0):
+ self.interval = interval
+ self.monitoring = False
+ self.data = []
+
+ def start_monitoring(self):
+ """开始监控"""
+ self.monitoring = True
+ self.monitor_thread = threading.Thread(target=self._monitor_loop)
+ self.monitor_thread.daemon = True
+ self.monitor_thread.start()
+ print("📊 系统监控已启动")
+
+ def stop_monitoring(self):
+ """停止监控"""
+ self.monitoring = False
+ if hasattr(self, 'monitor_thread'):
+ self.monitor_thread.join()
+ print("📊 系统监控已停止")
+
+ def _monitor_loop(self):
+ """监控循环"""
+ while self.monitoring:
+ cpu_percent = psutil.cpu_percent()
+ memory = psutil.virtual_memory()
+
+ self.data.append({
+ 'timestamp': time.time(),
+ 'cpu_percent': cpu_percent,
+ 'memory_percent': memory.percent,
+ 'memory_used_mb': memory.used / 1024 / 1024
+ })
+
+ time.sleep(self.interval)
+
+ def get_report(self):
+ """生成监控报告"""
+ if not self.data:
+ return "无监控数据"
+
+ avg_cpu = sum(d['cpu_percent'] for d in self.data) / len(self.data)
+ avg_memory = sum(d['memory_percent'] for d in self.data) / len(self.data)
+ max_memory = max(d['memory_used_mb'] for d in self.data)
+
+ return f"""
+📊 系统资源监控报告
+==================
+监控时长: {len(self.data) * self.interval:.1f}秒
+平均CPU使用率: {avg_cpu:.1f}%
+平均内存使用率: {avg_memory:.1f}%
+峰值内存使用: {max_memory:.1f}MB
+ """
+
+# 使用示例
+monitor = SystemMonitor()
+monitor.start_monitoring()
+
+# 执行设备操作
+# ... 你的代码 ...
+
+monitor.stop_monitoring()
+print(monitor.get_report())
+```
+
+### 4. 错误追踪
+
+#### 异常处理和记录
+```python
+import traceback
+
+class ErrorTracker:
+ """错误追踪器"""
+
+ def __init__(self):
+ self.errors = []
+
+ def log_error(self, operation, error, context=None):
+ """记录错误"""
+ error_info = {
+ 'timestamp': time.time(),
+ 'operation': operation,
+ 'error_type': type(error).__name__,
+ 'error_message': str(error),
+ 'traceback': traceback.format_exc(),
+ 'context': context or {}
+ }
+
+ self.errors.append(error_info)
+
+ # 记录到日志
+ logging.error(f"❌ {operation} 失败: {error}")
+ logging.debug(f"错误详情: {error_info}")
+
+ def get_error_summary(self):
+ """获取错误摘要"""
+ if not self.errors:
+ return "✅ 无错误记录"
+
+ error_types = {}
+ for error in self.errors:
+ error_type = error['error_type']
+ error_types[error_type] = error_types.get(error_type, 0) + 1
+
+ summary = f"❌ 共记录 {len(self.errors)} 个错误:\n"
+ for error_type, count in error_types.items():
+ summary += f" - {error_type}: {count} 次\n"
+
+ return summary
+
+# 全局错误追踪器
+error_tracker = ErrorTracker()
+
+# 使用示例
+try:
+ await device.move_to(x=1000, y=1000, z=100) # 可能超出范围
+except Exception as e:
+ error_tracker.log_error("移动操作", e, {"target": (1000, 1000, 100)})
+```
+
+---
+
+## 📚 总结
+
+本文档提供了LaiYu液体处理设备的完整硬件连接配置指南,涵盖了从基础设置到高级故障排除的所有方面。
+
+### 🎯 关键要点
+
+1. **标准配置**: 使用 `port="/dev/cu.usbserial-3130"`, `address=4`, `baudrate=115200`
+2. **设备架构**: XYZ轴控制器(地址1-3) + SOPA移液器(地址4)
+3. **连接验证**: 使用提供的测试脚本验证硬件连接
+4. **故障排除**: 参考故障排除指南解决常见问题
+5. **性能监控**: 启用日志和监控确保稳定运行
+
+### 🔗 相关文档
+
+- [LaiYu控制架构详解](./UniLab_LaiYu_控制架构详解.md)
+- [XYZ集成功能说明](./XYZ_集成功能说明.md)
+- [设备API文档](./readme.md)
+
+### 📞 技术支持
+
+如遇到问题,请:
+1. 检查硬件连接和配置
+2. 查看调试日志
+3. 参考故障排除指南
+4. 联系技术支持团队
+
+---
+
+*最后更新: 2024年1月*
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/docs/readme.md b/unilabos/devices/laiyu_liquid/docs/readme.md
new file mode 100644
index 00000000..4927138a
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/docs/readme.md
@@ -0,0 +1,269 @@
+# 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导出和版本管理
+ - 依赖检查和安装验证
+ - 专业的模块信息展示
+
+
+
+## 🚀 核心功能特性
+
+### 液体处理能力
+- **精密体积控制**: 支持1-1000μL精确分配
+- **多种液体类型**: 水性、有机溶剂、粘稠液体等
+- **智能检测**: 液位检测、气泡检测、堵塞检测
+- **自动化流程**: 完整的吸取-转移-分配工作流
+
+### 运动控制系统
+- **三轴精密定位**: 微米级精度控制
+- **路径优化**: 智能运动规划和碰撞避免
+- **安全机制**: 限位保护、紧急停止、错误恢复
+- **坐标系管理**: 工作坐标与机械坐标的自动转换
+
+### 资源管理
+- **标准化器具**: 支持96孔板、离心管架、枪头架等
+- **状态跟踪**: 实时监控液体体积、枪头状态等
+- **自动配置**: 基于JSON的灵活配置系统
+- **扩展性**: 易于添加新的器具类型
+
+## 📁 目录结构
+
+```
+LaiYu_Liquid/
+├── __init__.py # 模块初始化和API导出
+├── readme.md # 本文档
+├── 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设计
+
+
diff --git a/unilabos/devices/laiyu_liquid/drivers/__init__.py b/unilabos/devices/laiyu_liquid/drivers/__init__.py
new file mode 100644
index 00000000..cedd47a0
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/drivers/__init__.py
@@ -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 硬件驱动程序集合"
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py b/unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
new file mode 100644
index 00000000..3cfed55f
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
@@ -0,0 +1,1079 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+SOPA气动式移液器RS485控制驱动程序
+
+基于SOPA气动式移液器RS485控制指令合集编写的Python驱动程序,
+支持完整的移液器控制功能,包括移液、检测、配置等操作。
+
+仅支持SC-STxxx-00-13型号的RS485通信。
+"""
+
+import serial
+import time
+import logging
+import threading
+from typing import Optional, Union, Dict, Any, Tuple, List
+from enum import Enum, IntEnum
+from dataclasses import dataclass
+from contextlib import contextmanager
+
+# 配置日志
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+class SOPAError(Exception):
+ """SOPA移液器异常基类"""
+ pass
+
+
+class SOPACommunicationError(SOPAError):
+ """通信异常"""
+ pass
+
+
+class SOPADeviceError(SOPAError):
+ """设备异常"""
+ pass
+
+
+class SOPAStatusCode(IntEnum):
+ """状态码枚举"""
+ NO_ERROR = 0x00 # 无错误
+ ACTION_INCOMPLETE = 0x01 # 上次动作未完成
+ NOT_INITIALIZED = 0x02 # 设备未初始化
+ DEVICE_OVERLOAD = 0x03 # 设备过载
+ INVALID_COMMAND = 0x04 # 无效指令
+ LLD_FAULT = 0x05 # 液位探测故障
+ AIR_ASPIRATE = 0x0D # 空吸
+ NEEDLE_BLOCK = 0x0E # 堵针
+ FOAM_DETECT = 0x10 # 泡沫
+ EXCEED_TIP_VOLUME = 0x11 # 吸液超过吸头容量
+
+
+class CommunicationType(Enum):
+ """通信类型"""
+ TERMINAL_DEBUG = "/" # 终端调试,头码为0x2F
+ OEM_COMMUNICATION = "[" # OEM通信,头码为0x5B
+
+
+class DetectionMode(IntEnum):
+ """液位检测模式"""
+ PRESSURE = 0 # 压力式检测(pLLD)
+ CAPACITIVE = 1 # 电容式检测(cLLD)
+
+
+@dataclass
+class SOPAConfig:
+ """SOPA移液器配置参数"""
+ # 通信参数
+ port: str = "/dev/ttyUSB0"
+ baudrate: int = 115200
+ address: int = 1
+ timeout: float = 5.0
+ comm_type: CommunicationType = CommunicationType.TERMINAL_DEBUG
+
+ # 运动参数 (单位: 0.1ul/秒)
+ max_speed: int = 2000 # 最高速度 200ul/秒
+ start_speed: int = 200 # 启动速度 20ul/秒
+ cutoff_speed: int = 200 # 断流速度 20ul/秒
+ acceleration: int = 30000 # 加速度
+
+ # 检测参数
+ empty_threshold: int = 4 # 空吸门限
+ foam_threshold: int = 20 # 泡沫门限
+ block_threshold: int = 350 # 堵塞门限
+
+ # 液位检测参数
+ lld_speed: int = 200 # 检测速度 (100~2000)
+ lld_sensitivity: int = 5 # 检测灵敏度 (3~40)
+ detection_mode: DetectionMode = DetectionMode.PRESSURE
+
+ # 吸头参数
+ tip_volume: int = 1000 # 吸头容量 (ul)
+ calibration_factor: float = 1.0 # 校准系数
+ compensation_offset: float = 0.0 # 补偿偏差
+
+ def __post_init__(self):
+ """初始化后验证参数"""
+ self._validate_address()
+
+ def _validate_address(self):
+ """
+ 验证设备地址是否符合协议要求
+
+ 协议要求:
+ - 地址范围:1~254
+ - 禁用地址:47, 69, 91 (对应ASCII字符 '/', 'E', '[')
+ """
+ if not (1 <= self.address <= 254):
+ raise ValueError(f"设备地址必须在1-254范围内,当前地址: {self.address}")
+
+ forbidden_addresses = [47, 69, 91] # '/', 'E', '['
+ if self.address in forbidden_addresses:
+ forbidden_chars = {47: "'/' (0x2F)", 69: "'E' (0x45)", 91: "'[' (0x5B)"}
+ char_desc = forbidden_chars[self.address]
+ raise ValueError(
+ f"地址 {self.address} 不可用,因为它对应协议字符 {char_desc}。"
+ f"请选择其他地址(1-254,排除47、69、91)"
+ )
+
+
+class SOPAPipette:
+ """SOPA气动式移液器驱动类"""
+
+ def __init__(self, config: SOPAConfig):
+ """
+ 初始化SOPA移液器
+
+ Args:
+ config: 移液器配置参数
+ """
+ self.config = config
+ self.serial_port: Optional[serial.Serial] = None
+ self.is_connected = False
+ self.is_initialized = False
+ self.lock = threading.Lock()
+
+ # 状态缓存
+ self._last_status = SOPAStatusCode.NOT_INITIALIZED
+ self._current_position = 0
+ self._tip_present = False
+
+ def connect(self) -> bool:
+ """
+ 连接移液器
+
+ Returns:
+ bool: 连接是否成功
+ """
+ try:
+ self.serial_port = serial.Serial(
+ port=self.config.port,
+ baudrate=self.config.baudrate,
+ bytesize=serial.EIGHTBITS,
+ parity=serial.PARITY_NONE,
+ stopbits=serial.STOPBITS_ONE,
+ timeout=self.config.timeout
+ )
+
+ if self.serial_port.is_open:
+ self.is_connected = True
+ logger.info(f"已连接到SOPA移液器,端口: {self.config.port}, 地址: {self.config.address}")
+
+ # 查询设备信息
+ version = self.get_firmware_version()
+ if version:
+ logger.info(f"固件版本: {version}")
+
+ return True
+ else:
+ raise SOPACommunicationError("串口打开失败")
+
+ except Exception as e:
+ logger.error(f"连接失败: {str(e)}")
+ self.is_connected = False
+ return False
+
+ def disconnect(self):
+ """断开连接"""
+ if self.serial_port and self.serial_port.is_open:
+ self.serial_port.close()
+ self.is_connected = False
+ self.is_initialized = False
+ logger.info("已断开SOPA移液器连接")
+
+ def _calculate_checksum(self, data: bytes) -> int:
+ """计算校验和"""
+ return sum(data) & 0xFF
+
+ def _build_command(self, command: str) -> bytes:
+ """
+ 构建完整命令字节串
+
+ 根据协议格式:头码 + 地址 + 命令/数据 + 尾码 + 校验和
+
+ Args:
+ command: 命令字符串
+
+ Returns:
+ bytes: 完整的命令字节串
+ """
+ header = self.config.comm_type.value # '/' 或 '['
+ address = str(self.config.address) # 设备地址
+ tail = "E" # 尾码固定为 'E'
+
+ # 构建基础命令字符串:头码 + 地址 + 命令 + 尾码
+ cmd_str = f"{header}{address}{command}{tail}"
+
+ # 转换为字节串
+ cmd_bytes = cmd_str.encode('ascii')
+
+ # 计算校验和(所有字节的累加值)
+ checksum = self._calculate_checksum(cmd_bytes)
+
+ # 返回完整命令:基础命令字节 + 校验和字节
+ return cmd_bytes + bytes([checksum])
+
+ def _send_command(self, command: str) -> bool:
+ """
+ 发送命令到移液器
+
+ Args:
+ command: 要发送的命令
+
+ Returns:
+ bool: 命令是否发送成功
+ """
+ if not self.is_connected or not self.serial_port:
+ raise SOPACommunicationError("设备未连接")
+
+ with self.lock:
+ try:
+ full_command_bytes = self._build_command(command)
+ # 转换为可读字符串用于日志显示
+ readable_cmd = ''.join(chr(b) if 32 <= b <= 126 else f'\\x{b:02X}' for b in full_command_bytes)
+ logger.debug(f"发送命令: {readable_cmd}")
+
+ self.serial_port.write(full_command_bytes)
+ self.serial_port.flush()
+
+ # 等待响应
+ time.sleep(0.1)
+ return True
+
+ except Exception as e:
+ logger.error(f"发送命令失败: {str(e)}")
+ raise SOPACommunicationError(f"发送命令失败: {str(e)}")
+
+ def _read_response(self, timeout: float = None) -> Optional[str]:
+ """
+ 读取设备响应
+
+ Args:
+ timeout: 超时时间
+
+ Returns:
+ Optional[str]: 设备响应字符串
+ """
+ if not self.is_connected or not self.serial_port:
+ return None
+
+ timeout = timeout or self.config.timeout
+
+ try:
+ # 设置读取超时
+ self.serial_port.timeout = timeout
+
+ response = b''
+ start_time = time.time()
+
+ while time.time() - start_time < timeout:
+ if self.serial_port.in_waiting > 0:
+ chunk = self.serial_port.read(self.serial_port.in_waiting)
+ response += chunk
+
+ # 检查是否收到完整响应(以'E'结尾)
+ if response.endswith(b'E') or len(response) >= 20:
+ break
+
+ time.sleep(0.01)
+
+ if response:
+ decoded_response = response.decode('ascii', errors='ignore')
+ logger.debug(f"收到响应: {decoded_response}")
+ return decoded_response
+
+ except Exception as e:
+ logger.error(f"读取响应失败: {str(e)}")
+
+ return None
+
+ def _send_query(self, query: str) -> Optional[str]:
+ """
+ 发送查询命令并获取响应
+
+ Args:
+ query: 查询命令
+
+ Returns:
+ Optional[str]: 查询结果
+ """
+ try:
+ self._send_command(query)
+ return self._read_response()
+ except Exception as e:
+ logger.error(f"查询失败: {str(e)}")
+ return None
+
+ # ==================== 基础控制方法 ====================
+
+ def initialize(self) -> bool:
+ """
+ 初始化移液器
+
+ Returns:
+ bool: 初始化是否成功
+ """
+ try:
+ logger.info("初始化SOPA移液器...")
+
+ # 发送初始化命令
+ self._send_command("HE")
+
+ # 等待初始化完成
+ time.sleep(2.0)
+
+ # 检查状态
+ status = self.get_status()
+ if status == SOPAStatusCode.NO_ERROR:
+ self.is_initialized = True
+ logger.info("移液器初始化成功")
+
+ # 应用配置参数
+ self._apply_configuration()
+ return True
+ else:
+ logger.error(f"初始化失败,状态码: {status}")
+ return False
+
+ except Exception as e:
+ logger.error(f"初始化异常: {str(e)}")
+ return False
+
+ def _apply_configuration(self):
+ """应用配置参数"""
+ try:
+ # 设置运动参数
+ self.set_acceleration(self.config.acceleration)
+ self.set_start_speed(self.config.start_speed)
+ self.set_cutoff_speed(self.config.cutoff_speed)
+ self.set_max_speed(self.config.max_speed)
+
+ # 设置检测参数
+ self.set_empty_threshold(self.config.empty_threshold)
+ self.set_foam_threshold(self.config.foam_threshold)
+ self.set_block_threshold(self.config.block_threshold)
+
+ # 设置吸头参数
+ self.set_tip_volume(self.config.tip_volume)
+ self.set_calibration_factor(self.config.calibration_factor)
+
+ # 设置液位检测参数
+ self.set_detection_mode(self.config.detection_mode)
+ self.set_lld_speed(self.config.lld_speed)
+
+ logger.info("配置参数应用完成")
+
+ except Exception as e:
+ logger.warning(f"应用配置参数失败: {str(e)}")
+
+ def eject_tip(self) -> bool:
+ """
+ 顶出枪头
+
+ Returns:
+ bool: 操作是否成功
+ """
+ try:
+ logger.info("顶出枪头")
+ self._send_command("RE")
+ time.sleep(1.0)
+ return True
+ except Exception as e:
+ logger.error(f"顶出枪头失败: {str(e)}")
+ return False
+
+ def get_tip_status(self) -> bool:
+ """
+ 获取枪头状态
+
+ Returns:
+ bool: True表示有枪头,False表示无枪头
+ """
+ try:
+ response = self._send_query("Q28")
+ if response and len(response) > 10:
+ # 解析响应中的枪头状态
+ status_char = response[10] if len(response) > 10 else '0'
+ self._tip_present = (status_char == '1')
+ return self._tip_present
+ except Exception as e:
+ logger.error(f"获取枪头状态失败: {str(e)}")
+
+ return False
+
+ # ==================== 移液控制方法 ====================
+
+ def move_absolute(self, position: float) -> bool:
+ """
+ 绝对位置移动
+
+ Args:
+ position: 目标位置(微升)
+
+ Returns:
+ bool: 移动是否成功
+ """
+ try:
+ if not self.is_initialized:
+ raise SOPADeviceError("设备未初始化")
+
+ pos_int = int(position)
+ logger.debug(f"绝对移动到位置: {pos_int}ul")
+
+ self._send_command(f"A{pos_int}E")
+ time.sleep(0.5)
+
+ self._current_position = pos_int
+ return True
+
+ except Exception as e:
+ logger.error(f"绝对移动失败: {str(e)}")
+ return False
+
+ def aspirate(self, volume: float, detection: bool = False) -> bool:
+ """
+ 抽吸液体
+
+ Args:
+ volume: 抽吸体积(微升)
+ detection: 是否开启液体检测
+
+ Returns:
+ bool: 抽吸是否成功
+ """
+ try:
+ if not self.is_initialized:
+ raise SOPADeviceError("设备未初始化")
+
+ vol_int = int(volume)
+ logger.info(f"抽吸液体: {vol_int}ul, 检测: {detection}")
+
+ # 构建命令
+ cmd_parts = []
+ cmd_parts.append(f"a{self.config.acceleration}")
+ cmd_parts.append(f"b{self.config.start_speed}")
+ cmd_parts.append(f"c{self.config.cutoff_speed}")
+ cmd_parts.append(f"s{self.config.max_speed}")
+
+ if detection:
+ cmd_parts.append("f1") # 开启检测
+
+ cmd_parts.append(f"P{vol_int}")
+
+ if detection:
+ cmd_parts.append("f0") # 关闭检测
+
+ cmd_parts.append("E")
+
+ command = "".join(cmd_parts)
+ self._send_command(command)
+
+ # 等待操作完成
+ time.sleep(max(1.0, vol_int / 100.0))
+
+ # 检查状态
+ status = self.get_status()
+ if status == SOPAStatusCode.NO_ERROR:
+ self._current_position += vol_int
+ logger.info(f"抽吸成功: {vol_int}ul")
+ return True
+ elif status == SOPAStatusCode.AIR_ASPIRATE:
+ logger.warning("检测到空吸")
+ return False
+ elif status == SOPAStatusCode.NEEDLE_BLOCK:
+ logger.error("检测到堵针")
+ return False
+ else:
+ logger.error(f"抽吸失败,状态码: {status}")
+ return False
+
+ except Exception as e:
+ logger.error(f"抽吸失败: {str(e)}")
+ return False
+
+ def dispense(self, volume: float, detection: bool = False) -> bool:
+ """
+ 分配液体
+
+ Args:
+ volume: 分配体积(微升)
+ detection: 是否开启液体检测
+
+ Returns:
+ bool: 分配是否成功
+ """
+ try:
+ if not self.is_initialized:
+ raise SOPADeviceError("设备未初始化")
+
+ vol_int = int(volume)
+ logger.info(f"分配液体: {vol_int}ul, 检测: {detection}")
+
+ # 构建命令
+ cmd_parts = []
+ cmd_parts.append(f"a{self.config.acceleration}")
+ cmd_parts.append(f"b{self.config.start_speed}")
+ cmd_parts.append(f"c{self.config.cutoff_speed}")
+ cmd_parts.append(f"s{self.config.max_speed}")
+
+ if detection:
+ cmd_parts.append("f1") # 开启检测
+
+ cmd_parts.append(f"D{vol_int}")
+
+ if detection:
+ cmd_parts.append("f0") # 关闭检测
+
+ cmd_parts.append("E")
+
+ command = "".join(cmd_parts)
+ self._send_command(command)
+
+ # 等待操作完成
+ time.sleep(max(1.0, vol_int / 200.0))
+
+ # 检查状态
+ status = self.get_status()
+ if status == SOPAStatusCode.NO_ERROR:
+ self._current_position -= vol_int
+ logger.info(f"分配成功: {vol_int}ul")
+ return True
+ else:
+ logger.error(f"分配失败,状态码: {status}")
+ return False
+
+ except Exception as e:
+ logger.error(f"分配失败: {str(e)}")
+ return False
+
+ # ==================== 液位检测方法 ====================
+
+ def liquid_level_detection(self, sensitivity: int = None) -> bool:
+ """
+ 执行液位检测
+
+ Args:
+ sensitivity: 检测灵敏度 (3~40)
+
+ Returns:
+ bool: 检测是否成功
+ """
+ try:
+ if not self.is_initialized:
+ raise SOPADeviceError("设备未初始化")
+
+ sens = sensitivity or self.config.lld_sensitivity
+
+ if self.config.detection_mode == DetectionMode.PRESSURE:
+ # 压力式液面检测
+ command = f"m0k{self.config.lld_speed}L{sens}E"
+ else:
+ # 电容式液面检测
+ command = f"m1L{sens}E"
+
+ logger.info(f"执行液位检测, 模式: {self.config.detection_mode.name}, 灵敏度: {sens}")
+
+ self._send_command(command)
+ time.sleep(2.0)
+
+ # 检查检测结果
+ status = self.get_status()
+ if status == SOPAStatusCode.NO_ERROR:
+ logger.info("液位检测成功")
+ return True
+ elif status == SOPAStatusCode.LLD_FAULT:
+ logger.error("液位检测故障")
+ return False
+ else:
+ logger.warning(f"液位检测异常,状态码: {status}")
+ return False
+
+ except Exception as e:
+ logger.error(f"液位检测失败: {str(e)}")
+ return False
+
+ # ==================== 参数设置方法 ====================
+
+ def set_max_speed(self, speed: int) -> bool:
+ """设置最高速度 (0.1ul/秒为单位)"""
+ try:
+ self._send_command(f"s{speed}E")
+ self.config.max_speed = speed
+ logger.debug(f"设置最高速度: {speed} (0.1ul/秒)")
+ return True
+ except Exception as e:
+ logger.error(f"设置最高速度失败: {str(e)}")
+ return False
+
+ def set_start_speed(self, speed: int) -> bool:
+ """设置启动速度 (0.1ul/秒为单位)"""
+ try:
+ self._send_command(f"b{speed}E")
+ self.config.start_speed = speed
+ logger.debug(f"设置启动速度: {speed} (0.1ul/秒)")
+ return True
+ except Exception as e:
+ logger.error(f"设置启动速度失败: {str(e)}")
+ return False
+
+ def set_cutoff_speed(self, speed: int) -> bool:
+ """设置断流速度 (0.1ul/秒为单位)"""
+ try:
+ self._send_command(f"c{speed}E")
+ self.config.cutoff_speed = speed
+ logger.debug(f"设置断流速度: {speed} (0.1ul/秒)")
+ return True
+ except Exception as e:
+ logger.error(f"设置断流速度失败: {str(e)}")
+ return False
+
+ def set_acceleration(self, accel: int) -> bool:
+ """设置加速度"""
+ try:
+ self._send_command(f"a{accel}E")
+ self.config.acceleration = accel
+ logger.debug(f"设置加速度: {accel}")
+ return True
+ except Exception as e:
+ logger.error(f"设置加速度失败: {str(e)}")
+ return False
+
+ def set_empty_threshold(self, threshold: int) -> bool:
+ """设置空吸门限"""
+ try:
+ self._send_command(f"${threshold}E")
+ self.config.empty_threshold = threshold
+ logger.debug(f"设置空吸门限: {threshold}")
+ return True
+ except Exception as e:
+ logger.error(f"设置空吸门限失败: {str(e)}")
+ return False
+
+ def set_foam_threshold(self, threshold: int) -> bool:
+ """设置泡沫门限"""
+ try:
+ self._send_command(f"!{threshold}E")
+ self.config.foam_threshold = threshold
+ logger.debug(f"设置泡沫门限: {threshold}")
+ return True
+ except Exception as e:
+ logger.error(f"设置泡沫门限失败: {str(e)}")
+ return False
+
+ def set_block_threshold(self, threshold: int) -> bool:
+ """设置堵塞门限"""
+ try:
+ self._send_command(f"%{threshold}E")
+ self.config.block_threshold = threshold
+ logger.debug(f"设置堵塞门限: {threshold}")
+ return True
+ except Exception as e:
+ logger.error(f"设置堵塞门限失败: {str(e)}")
+ return False
+
+ def set_tip_volume(self, volume: int) -> bool:
+ """设置吸头容量"""
+ try:
+ self._send_command(f"C{volume}E")
+ self.config.tip_volume = volume
+ logger.debug(f"设置吸头容量: {volume}ul")
+ return True
+ except Exception as e:
+ logger.error(f"设置吸头容量失败: {str(e)}")
+ return False
+
+ def set_calibration_factor(self, factor: float) -> bool:
+ """设置校准系数"""
+ try:
+ self._send_command(f"j{factor}E")
+ self.config.calibration_factor = factor
+ logger.debug(f"设置校准系数: {factor}")
+ return True
+ except Exception as e:
+ logger.error(f"设置校准系数失败: {str(e)}")
+ return False
+
+ def set_detection_mode(self, mode: DetectionMode) -> bool:
+ """设置液位检测模式"""
+ try:
+ self._send_command(f"m{mode.value}E")
+ self.config.detection_mode = mode
+ logger.debug(f"设置检测模式: {mode.name}")
+ return True
+ except Exception as e:
+ logger.error(f"设置检测模式失败: {str(e)}")
+ return False
+
+ def set_lld_speed(self, speed: int) -> bool:
+ """设置液位检测速度"""
+ try:
+ if 100 <= speed <= 2000:
+ self._send_command(f"k{speed}E")
+ self.config.lld_speed = speed
+ logger.debug(f"设置检测速度: {speed}")
+ return True
+ else:
+ logger.error("检测速度超出范围 (100~2000)")
+ return False
+ except Exception as e:
+ logger.error(f"设置检测速度失败: {str(e)}")
+ return False
+
+ # ==================== 状态查询方法 ====================
+
+ def get_status(self) -> SOPAStatusCode:
+ """
+ 获取设备状态
+
+ Returns:
+ SOPAStatusCode: 当前状态码
+ """
+ try:
+ response = self._send_query("Q")
+ if response and len(response) > 8:
+ # 解析状态字节
+ status_char = response[8] if len(response) > 8 else '0'
+ try:
+ status_code = int(status_char, 16) if status_char.isdigit() or status_char.lower() in 'abcdef' else 0
+ self._last_status = SOPAStatusCode(status_code)
+ except ValueError:
+ self._last_status = SOPAStatusCode.NO_ERROR
+
+ return self._last_status
+ except Exception as e:
+ logger.error(f"获取状态失败: {str(e)}")
+
+ return SOPAStatusCode.NO_ERROR
+
+ def get_firmware_version(self) -> Optional[str]:
+ """
+ 获取固件版本信息
+ 处理SOPA移液器的双响应帧格式
+
+ Returns:
+ Optional[str]: 固件版本字符串,获取失败返回None
+ """
+ try:
+ if not self.is_connected:
+ logger.debug("设备未连接,无法查询版本")
+ return "设备未连接"
+
+ # 清空串口缓冲区,避免残留数据干扰
+ if self.serial_port and self.serial_port.in_waiting > 0:
+ logger.debug(f"清空缓冲区中的 {self.serial_port.in_waiting} 字节数据")
+ self.serial_port.reset_input_buffer()
+
+ # 发送版本查询命令 - 使用VE命令
+ command = self._build_command("VE")
+ logger.debug(f"发送版本查询命令: {command}")
+ self.serial_port.write(command)
+
+ # 等待响应
+ time.sleep(0.3) # 增加等待时间
+
+ # 读取所有可用数据
+ all_data = b''
+ timeout_count = 0
+ max_timeout = 15 # 增加最大等待时间到1.5秒
+
+ while timeout_count < max_timeout:
+ if self.serial_port.in_waiting > 0:
+ data = self.serial_port.read(self.serial_port.in_waiting)
+ all_data += data
+ logger.debug(f"接收到 {len(data)} 字节数据: {data.hex().upper()}")
+ timeout_count = 0 # 重置超时计数
+ else:
+ time.sleep(0.1)
+ timeout_count += 1
+
+ # 检查是否收到完整的双响应帧
+ if len(all_data) >= 26: # 两个13字节的响应帧
+ logger.debug("收到完整的双响应帧")
+ break
+ elif len(all_data) >= 13: # 至少一个响应帧
+ # 继续等待一段时间看是否有第二个帧
+ if timeout_count > 5: # 等待0.5秒后如果没有更多数据就停止
+ logger.debug("只收到单响应帧")
+ break
+
+ logger.debug(f"总共接收到 {len(all_data)} 字节数据: {all_data.hex().upper()}")
+
+ if len(all_data) < 13:
+ logger.warning("接收到的数据不足一个完整响应帧")
+ return "版本信息不可用"
+
+ # 解析响应数据
+ version_info = self._parse_version_response(all_data)
+ logger.info(f"解析得到版本信息: {version_info}")
+ return version_info
+
+ except Exception as e:
+ logger.error(f"获取固件版本失败: {str(e)}")
+ return "版本信息不可用"
+
+ def _parse_version_response(self, data: bytes) -> str:
+ """
+ 解析版本响应数据
+
+ Args:
+ data: 原始响应数据
+
+ Returns:
+ str: 解析后的版本信息
+ """
+ try:
+ # 将数据转换为十六进制字符串用于调试
+ hex_data = data.hex().upper()
+ logger.debug(f"收到版本响应数据: {hex_data}")
+
+ # 查找响应帧的起始位置
+ responses = []
+ i = 0
+ while i < len(data) - 12:
+ # 查找帧头 0x2F (/)
+ if data[i] == 0x2F:
+ # 检查是否是完整的13字节帧
+ if i + 12 < len(data) and data[i + 11] == 0x45: # 尾码 E
+ frame = data[i:i+13]
+ responses.append(frame)
+ i += 13
+ else:
+ i += 1
+ else:
+ i += 1
+
+ if len(responses) < 2:
+ # 如果只有一个响应帧,尝试解析
+ if len(responses) == 1:
+ return self._extract_version_from_frame(responses[0])
+ else:
+ return f"响应格式异常: {hex_data}"
+
+ # 解析第二个响应帧(通常包含版本信息)
+ version_frame = responses[1]
+ return self._extract_version_from_frame(version_frame)
+
+ except Exception as e:
+ logger.error(f"解析版本响应失败: {str(e)}")
+ return f"解析失败: {data.hex().upper()}"
+
+ def _extract_version_from_frame(self, frame: bytes) -> str:
+ """
+ 从响应帧中提取版本信息
+
+ Args:
+ frame: 13字节的响应帧
+
+ Returns:
+ str: 版本信息字符串
+ """
+ try:
+ # 帧格式: 头码(1) + 地址(1) + 数据(9) + 尾码(1) + 校验和(1)
+ if len(frame) != 13:
+ return f"帧长度错误: {frame.hex().upper()}"
+
+ # 提取数据部分 (索引2-10,共9字节)
+ data_part = frame[2:11]
+
+ # 尝试不同的解析方法
+ version_candidates = []
+
+ # 方法1: 查找可打印的ASCII字符
+ ascii_chars = []
+ for byte in data_part:
+ if 32 <= byte <= 126: # 可打印ASCII范围
+ ascii_chars.append(chr(byte))
+
+ if ascii_chars:
+ version_candidates.append(''.join(ascii_chars))
+
+ # 方法2: 解析为版本号格式 (如果前几个字节是版本信息)
+ if len(data_part) >= 3:
+ # 检查是否是 V.x.y 格式
+ if data_part[0] == 0x56: # 'V'
+ version_str = f"V{data_part[1]}.{data_part[2]}"
+ version_candidates.append(version_str)
+
+ # 方法3: 十六进制表示
+ hex_version = ' '.join(f'{b:02X}' for b in data_part)
+ version_candidates.append(f"HEX: {hex_version}")
+
+ # 返回最合理的版本信息
+ for candidate in version_candidates:
+ if candidate and len(candidate.strip()) > 1:
+ return candidate.strip()
+
+ return f"原始数据: {frame.hex().upper()}"
+
+ except Exception as e:
+ logger.error(f"提取版本信息失败: {str(e)}")
+ return f"提取失败: {frame.hex().upper()}"
+
+ def get_current_position(self) -> float:
+ """
+ 获取当前位置
+
+ Returns:
+ float: 当前位置 (微升)
+ """
+ try:
+ response = self._send_query("Q18")
+ if response and len(response) > 10:
+ # 解析位置信息
+ pos_str = response[8:14].strip()
+ try:
+ self._current_position = int(pos_str)
+ except ValueError:
+ pass
+ except Exception as e:
+ logger.error(f"获取位置失败: {str(e)}")
+
+ return self._current_position
+
+ def get_device_info(self) -> Dict[str, Any]:
+ """
+ 获取设备完整信息
+
+ Returns:
+ Dict[str, Any]: 设备信息字典
+ """
+ info = {
+ 'firmware_version': self.get_firmware_version(),
+ 'current_position': self.get_current_position(),
+ 'tip_present': self.get_tip_status(),
+ 'status': self.get_status(),
+ 'is_connected': self.is_connected,
+ 'is_initialized': self.is_initialized,
+ 'config': {
+ 'address': self.config.address,
+ 'baudrate': self.config.baudrate,
+ 'max_speed': self.config.max_speed,
+ 'tip_volume': self.config.tip_volume,
+ 'detection_mode': self.config.detection_mode.name
+ }
+ }
+
+ return info
+
+ # ==================== 高级操作方法 ====================
+
+ def transfer_liquid(self, source_volume: float, dispense_volume: float = None,
+ with_detection: bool = True, pre_wet: bool = False) -> bool:
+ """
+ 完整的液体转移操作
+
+ Args:
+ source_volume: 从源容器抽吸的体积
+ dispense_volume: 分配到目标容器的体积(默认等于抽吸体积)
+ with_detection: 是否使用液体检测
+ pre_wet: 是否进行预润湿
+
+ Returns:
+ bool: 操作是否成功
+ """
+ try:
+ if not self.is_initialized:
+ raise SOPADeviceError("设备未初始化")
+
+ dispense_volume = dispense_volume or source_volume
+
+ logger.info(f"开始液体转移: 抽吸{source_volume}ul -> 分配{dispense_volume}ul")
+
+ # 预润湿(如果需要)
+ if pre_wet:
+ logger.info("执行预润湿操作")
+ if not self.aspirate(source_volume * 0.1, with_detection):
+ return False
+ if not self.dispense(source_volume * 0.1):
+ return False
+
+ # 执行液位检测(如果启用)
+ if with_detection:
+ if not self.liquid_level_detection():
+ logger.warning("液位检测失败,继续执行")
+
+ # 抽吸液体
+ if not self.aspirate(source_volume, with_detection):
+ logger.error("抽吸失败")
+ return False
+
+ # 可选的延时
+ time.sleep(0.5)
+
+ # 分配液体
+ if not self.dispense(dispense_volume, with_detection):
+ logger.error("分配失败")
+ return False
+
+ logger.info("液体转移完成")
+ return True
+
+ except Exception as e:
+ logger.error(f"液体转移失败: {str(e)}")
+ return False
+
+ @contextmanager
+ def batch_operation(self):
+ """批量操作上下文管理器"""
+ logger.info("开始批量操作")
+ try:
+ yield self
+ finally:
+ logger.info("批量操作完成")
+
+ def reset_to_home(self) -> bool:
+ """回到初始位置"""
+ return self.move_absolute(0)
+
+ def emergency_stop(self):
+ """紧急停止"""
+ try:
+ if self.serial_port and self.serial_port.is_open:
+ # 发送停止命令(如果协议支持)
+ self.serial_port.write(b'\x03') # Ctrl+C
+ logger.warning("执行紧急停止")
+ except Exception as e:
+ logger.error(f"紧急停止失败: {str(e)}")
+
+ def __enter__(self):
+ """上下文管理器入口"""
+ if not self.is_connected:
+ self.connect()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """上下文管理器出口"""
+ self.disconnect()
+
+ def __del__(self):
+ """析构函数"""
+ self.disconnect()
+
+
+# ==================== 工厂函数和便利方法 ====================
+
+def create_sopa_pipette(port: str = "/dev/ttyUSB0", address: int = 1,
+ baudrate: int = 115200, **kwargs) -> SOPAPipette:
+ """
+ 创建SOPA移液器实例的便利函数
+
+ Args:
+ port: 串口端口
+ address: RS485地址
+ baudrate: 波特率
+ **kwargs: 其他配置参数
+
+ Returns:
+ SOPAPipette: 移液器实例
+ """
+ config = SOPAConfig(
+ port=port,
+ address=address,
+ baudrate=baudrate,
+ **kwargs
+ )
+
+ return SOPAPipette(config)
diff --git a/unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py b/unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py
new file mode 100644
index 00000000..146cbb4e
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py
@@ -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(' 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("CRC校验失败")
+
+ 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
diff --git a/unilabos/devices/laiyu_liquid/tests/__init__.py b/unilabos/devices/laiyu_liquid/tests/__init__.py
new file mode 100644
index 00000000..7ff58fe2
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/tests/__init__.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+LaiYu液体处理设备测试模块
+
+该模块包含LaiYu液体处理设备的测试用例:
+- test_deck_config.py: 工作台配置测试
+
+作者: UniLab团队
+版本: 2.0.0
+"""
+
+__all__ = []
\ No newline at end of file
diff --git a/unilabos/devices/laiyu_liquid/tests/test_deck_config.py b/unilabos/devices/laiyu_liquid/tests/test_deck_config.py
new file mode 100644
index 00000000..04688302
--- /dev/null
+++ b/unilabos/devices/laiyu_liquid/tests/test_deck_config.py
@@ -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)
\ No newline at end of file
diff --git a/unilabos/devices/liquid_handling/rviz_backend.py b/unilabos/devices/liquid_handling/rviz_backend.py
new file mode 100644
index 00000000..05078a13
--- /dev/null
+++ b/unilabos/devices/liquid_handling/rviz_backend.py
@@ -0,0 +1,304 @@
+
+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
+
+
+class UniLiquidHandlerRvizBackend(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):
+ """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
+
+ async def setup(self):
+ self.joint_state_publisher = JointStatePublisher()
+ await super().setup()
+
+ 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}
+
+ @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#':<{UniLiquidHandlerRvizBackend._pip_length}} "
+ f"{'resource':<{UniLiquidHandlerRvizBackend._resource_length}} "
+ f"{'offset':<{UniLiquidHandlerRvizBackend._offset_length}} "
+ f"{'tip type':<{UniLiquidHandlerRvizBackend._tip_type_length}} "
+ f"{'max volume (µL)':<{UniLiquidHandlerRvizBackend._max_volume_length}} "
+ f"{'fitting depth (mm)':<{UniLiquidHandlerRvizBackend._fitting_depth_length}} "
+ f"{'tip length (mm)':<{UniLiquidHandlerRvizBackend._tip_length_length}} "
+ # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
+ f"{'filter':<{UniLiquidHandlerRvizBackend._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:]:<{UniLiquidHandlerRvizBackend._resource_length}} "
+ f"{offset:<{UniLiquidHandlerRvizBackend._offset_length}} "
+ f"{op.tip.__class__.__name__:<{UniLiquidHandlerRvizBackend._tip_type_length}} "
+ f"{op.tip.maximal_volume:<{UniLiquidHandlerRvizBackend._max_volume_length}} "
+ f"{op.tip.fitting_depth:<{UniLiquidHandlerRvizBackend._fitting_depth_length}} "
+ f"{op.tip.total_tip_length:<{UniLiquidHandlerRvizBackend._tip_length_length}} "
+ # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
+ f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerRvizBackend._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.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#':<{UniLiquidHandlerRvizBackend._pip_length}} "
+ f"{'resource':<{UniLiquidHandlerRvizBackend._resource_length}} "
+ f"{'offset':<{UniLiquidHandlerRvizBackend._offset_length}} "
+ f"{'tip type':<{UniLiquidHandlerRvizBackend._tip_type_length}} "
+ f"{'max volume (µL)':<{UniLiquidHandlerRvizBackend._max_volume_length}} "
+ f"{'fitting depth (mm)':<{UniLiquidHandlerRvizBackend._fitting_depth_length}} "
+ f"{'tip length (mm)':<{UniLiquidHandlerRvizBackend._tip_length_length}} "
+ # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
+ f"{'filter':<{UniLiquidHandlerRvizBackend._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:]:<{UniLiquidHandlerRvizBackend._resource_length}} "
+ f"{offset:<{UniLiquidHandlerRvizBackend._offset_length}} "
+ f"{op.tip.__class__.__name__:<{UniLiquidHandlerRvizBackend._tip_type_length}} "
+ f"{op.tip.maximal_volume:<{UniLiquidHandlerRvizBackend._max_volume_length}} "
+ f"{op.tip.fitting_depth:<{UniLiquidHandlerRvizBackend._fitting_depth_length}} "
+ f"{op.tip.total_tip_length:<{UniLiquidHandlerRvizBackend._tip_length_length}} "
+ # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
+ f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerRvizBackend._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
+ # 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)
+ # goback()
+
+ async def aspirate(
+ self,
+ ops: List[SingleChannelAspiration],
+ use_channels: List[int],
+ **backend_kwargs,
+ ):
+ print("Aspirating:")
+ header = (
+ f"{'pip#':<{UniLiquidHandlerRvizBackend._pip_length}} "
+ f"{'vol(ul)':<{UniLiquidHandlerRvizBackend._vol_length}} "
+ f"{'resource':<{UniLiquidHandlerRvizBackend._resource_length}} "
+ f"{'offset':<{UniLiquidHandlerRvizBackend._offset_length}} "
+ f"{'flow rate':<{UniLiquidHandlerRvizBackend._flow_rate_length}} "
+ f"{'blowout':<{UniLiquidHandlerRvizBackend._blowout_length}} "
+ f"{'lld_z':<{UniLiquidHandlerRvizBackend._lld_z_length}} "
+ # f"{'liquids':<20}" # TODO: add liquids
+ )
+ for key in backend_kwargs:
+ header += f"{key:<{UniLiquidHandlerRvizBackend._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:<{UniLiquidHandlerRvizBackend._vol_length}} "
+ f"{o.resource.name[-20:]:<{UniLiquidHandlerRvizBackend._resource_length}} "
+ f"{offset:<{UniLiquidHandlerRvizBackend._offset_length}} "
+ f"{str(o.flow_rate):<{UniLiquidHandlerRvizBackend._flow_rate_length}} "
+ f"{str(o.blow_out_air_volume):<{UniLiquidHandlerRvizBackend._blowout_length}} "
+ f"{str(o.liquid_height):<{UniLiquidHandlerRvizBackend._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.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
+
+
+ async def dispense(
+ self,
+ ops: List[SingleChannelDispense],
+ use_channels: List[int],
+ **backend_kwargs,
+ ):
+ # print("Dispensing:")
+ header = (
+ f"{'pip#':<{UniLiquidHandlerRvizBackend._pip_length}} "
+ f"{'vol(ul)':<{UniLiquidHandlerRvizBackend._vol_length}} "
+ f"{'resource':<{UniLiquidHandlerRvizBackend._resource_length}} "
+ f"{'offset':<{UniLiquidHandlerRvizBackend._offset_length}} "
+ f"{'flow rate':<{UniLiquidHandlerRvizBackend._flow_rate_length}} "
+ f"{'blowout':<{UniLiquidHandlerRvizBackend._blowout_length}} "
+ f"{'lld_z':<{UniLiquidHandlerRvizBackend._lld_z_length}} "
+ # f"{'liquids':<20}" # TODO: add liquids
+ )
+ for key in backend_kwargs:
+ header += f"{key:<{UniLiquidHandlerRvizBackend._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:<{UniLiquidHandlerRvizBackend._vol_length}} "
+ f"{o.resource.name[-20:]:<{UniLiquidHandlerRvizBackend._resource_length}} "
+ f"{offset:<{UniLiquidHandlerRvizBackend._offset_length}} "
+ f"{str(o.flow_rate):<{UniLiquidHandlerRvizBackend._flow_rate_length}} "
+ f"{str(o.blow_out_air_volume):<{UniLiquidHandlerRvizBackend._blowout_length}} "
+ f"{str(o.liquid_height):<{UniLiquidHandlerRvizBackend._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:<{UniLiquidHandlerRvizBackend._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.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
+
diff --git a/unilabos/registry/devices/laiyu_liquid.yaml b/unilabos/registry/devices/laiyu_liquid.yaml
new file mode 100644
index 00000000..1c6546e1
--- /dev/null
+++ b/unilabos/registry/devices/laiyu_liquid.yaml
@@ -0,0 +1,1963 @@
+laiyu_liquid:
+ category:
+ - liquid_handler
+ - workstation
+ - laiyu_liquid
+ class:
+ action_value_mappings:
+ add_liquid:
+ feedback: {}
+ goal:
+ asp_vols: asp_vols
+ dis_vols: dis_vols
+ flow_rates: flow_rates
+ offsets: offsets
+ reagent_sources: reagent_sources
+ targets: targets
+ use_channels: use_channels
+ goal_default:
+ asp_vols:
+ - 0.0
+ blow_out_air_volume:
+ - 0.0
+ dis_vols:
+ - 0.0
+ flow_rates:
+ - 0.0
+ is_96_well: false
+ liquid_height:
+ - 0.0
+ mix_liquid_height: 0.0
+ mix_rate: 0
+ mix_time: 0
+ mix_vol: 0
+ none_keys:
+ - ''
+ offsets:
+ - x: 0.0
+ y: 0.0
+ z: 0.0
+ reagent_sources:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ spread: ''
+ targets:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ use_channels:
+ - 0
+ handles: {}
+ placeholder_keys:
+ reagent_sources: unilabos_resources
+ targets: unilabos_resources
+ result: {}
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: LiquidHandlerAdd_Feedback
+ type: object
+ goal:
+ properties:
+ asp_vols:
+ items:
+ type: number
+ type: array
+ blow_out_air_volume:
+ items:
+ type: number
+ type: array
+ dis_vols:
+ items:
+ type: number
+ type: array
+ flow_rates:
+ items:
+ type: number
+ type: array
+ is_96_well:
+ type: boolean
+ liquid_height:
+ items:
+ type: number
+ type: array
+ mix_liquid_height:
+ type: number
+ mix_rate:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ mix_time:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ mix_vol:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ none_keys:
+ items:
+ type: string
+ type: array
+ offsets:
+ items:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: offsets
+ type: object
+ type: array
+ reagent_sources:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: reagent_sources
+ type: object
+ type: array
+ spread:
+ type: string
+ targets:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: targets
+ type: object
+ type: array
+ use_channels:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ required:
+ - asp_vols
+ - dis_vols
+ - reagent_sources
+ - targets
+ - use_channels
+ - flow_rates
+ - offsets
+ - liquid_height
+ - blow_out_air_volume
+ - spread
+ - is_96_well
+ - mix_time
+ - mix_vol
+ - mix_rate
+ - mix_liquid_height
+ - none_keys
+ title: LiquidHandlerAdd_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: LiquidHandlerAdd_Result
+ type: object
+ required:
+ - goal
+ title: LiquidHandlerAdd
+ type: object
+ type: LiquidHandlerAdd
+ aspirate:
+ feedback: {}
+ goal:
+ flow_rates: flow_rates
+ resources: resources
+ use_channels: use_channels
+ vols: vols
+ goal_default:
+ blow_out_air_volume:
+ - 0.0
+ flow_rates:
+ - 0.0
+ liquid_height:
+ - 0.0
+ offsets:
+ - x: 0.0
+ y: 0.0
+ z: 0.0
+ resources:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ spread: ''
+ use_channels:
+ - 0
+ vols:
+ - 0.0
+ handles: {}
+ placeholder_keys:
+ resources: unilabos_resources
+ result:
+ success: success
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: LiquidHandlerAspirate_Feedback
+ type: object
+ goal:
+ properties:
+ blow_out_air_volume:
+ items:
+ type: number
+ type: array
+ flow_rates:
+ items:
+ type: number
+ type: array
+ liquid_height:
+ items:
+ type: number
+ type: array
+ offsets:
+ items:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: offsets
+ type: object
+ type: array
+ resources:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: resources
+ type: object
+ type: array
+ spread:
+ type: string
+ use_channels:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ vols:
+ items:
+ type: number
+ type: array
+ required:
+ - resources
+ - vols
+ - use_channels
+ - flow_rates
+ - offsets
+ - liquid_height
+ - blow_out_air_volume
+ - spread
+ title: LiquidHandlerAspirate_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: LiquidHandlerAspirate_Result
+ type: object
+ required:
+ - goal
+ title: LiquidHandlerAspirate
+ type: object
+ type: LiquidHandlerAspirate
+ dispense:
+ feedback: {}
+ goal:
+ flow_rates: flow_rates
+ resources: resources
+ use_channels: use_channels
+ vols: vols
+ goal_default:
+ blow_out_air_volume:
+ - 0
+ flow_rates:
+ - 0.0
+ offsets:
+ - x: 0.0
+ y: 0.0
+ z: 0.0
+ resources:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ spread: ''
+ use_channels:
+ - 0
+ vols:
+ - 0.0
+ handles: {}
+ placeholder_keys:
+ resources: unilabos_resources
+ result:
+ success: success
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: LiquidHandlerDispense_Feedback
+ type: object
+ goal:
+ properties:
+ blow_out_air_volume:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ flow_rates:
+ items:
+ type: number
+ type: array
+ offsets:
+ items:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: offsets
+ type: object
+ type: array
+ resources:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: resources
+ type: object
+ type: array
+ spread:
+ type: string
+ use_channels:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ vols:
+ items:
+ type: number
+ type: array
+ required:
+ - resources
+ - vols
+ - use_channels
+ - flow_rates
+ - offsets
+ - blow_out_air_volume
+ - spread
+ title: LiquidHandlerDispense_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: LiquidHandlerDispense_Result
+ type: object
+ required:
+ - goal
+ title: LiquidHandlerDispense
+ type: object
+ type: LiquidHandlerDispense
+ drop_tip:
+ feedback: {}
+ goal:
+ use_channels: use_channels
+ goal_default:
+ allow_nonzero_volume: false
+ offsets:
+ - x: 0.0
+ y: 0.0
+ z: 0.0
+ tip_spots:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ use_channels:
+ - 0
+ handles: {}
+ result:
+ success: success
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: LiquidHandlerDropTips_Feedback
+ type: object
+ goal:
+ properties:
+ allow_nonzero_volume:
+ type: boolean
+ offsets:
+ items:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: offsets
+ type: object
+ type: array
+ tip_spots:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: tip_spots
+ type: object
+ type: array
+ use_channels:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ required:
+ - tip_spots
+ - use_channels
+ - offsets
+ - allow_nonzero_volume
+ title: LiquidHandlerDropTips_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: LiquidHandlerDropTips_Result
+ type: object
+ required:
+ - goal
+ title: LiquidHandlerDropTips
+ type: object
+ type: LiquidHandlerDropTips
+ move_to:
+ feedback: {}
+ goal:
+ channel: channel
+ dis_to_top: dis_to_top
+ well: well
+ goal_default:
+ channel: 0
+ dis_to_top: 0.0
+ well:
+ category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ handles: {}
+ placeholder_keys:
+ well: unilabos_resources
+ result:
+ success: success
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: LiquidHandlerMoveTo_Feedback
+ type: object
+ goal:
+ properties:
+ channel:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ dis_to_top:
+ type: number
+ well:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: well
+ type: object
+ required:
+ - well
+ - dis_to_top
+ - channel
+ title: LiquidHandlerMoveTo_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: LiquidHandlerMoveTo_Result
+ type: object
+ required:
+ - goal
+ title: LiquidHandlerMoveTo
+ type: object
+ type: LiquidHandlerMoveTo
+ pick_up_tip:
+ feedback: {}
+ goal:
+ tip_rack: tip_rack
+ use_channels: use_channels
+ goal_default:
+ offsets:
+ - x: 0.0
+ y: 0.0
+ z: 0.0
+ tip_spots:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ use_channels:
+ - 0
+ handles: {}
+ placeholder_keys:
+ tip_rack: unilabos_resources
+ result:
+ success: success
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: LiquidHandlerPickUpTips_Feedback
+ type: object
+ goal:
+ properties:
+ offsets:
+ items:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: offsets
+ type: object
+ type: array
+ tip_spots:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: tip_spots
+ type: object
+ type: array
+ use_channels:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ required:
+ - tip_spots
+ - use_channels
+ - offsets
+ title: LiquidHandlerPickUpTips_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: LiquidHandlerPickUpTips_Result
+ type: object
+ required:
+ - goal
+ title: LiquidHandlerPickUpTips
+ type: object
+ type: LiquidHandlerPickUpTips
+ setup:
+ feedback: {}
+ goal:
+ string: string
+ goal_default:
+ string: ''
+ handles: {}
+ result:
+ success: success
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: StrSingleInput_Feedback
+ type: object
+ goal:
+ properties:
+ string:
+ type: string
+ required:
+ - string
+ title: StrSingleInput_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: StrSingleInput_Result
+ type: object
+ required:
+ - goal
+ title: StrSingleInput
+ type: object
+ type: StrSingleInput
+ stop:
+ feedback: {}
+ goal:
+ string: string
+ goal_default:
+ string: ''
+ handles: {}
+ result:
+ success: success
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: StrSingleInput_Feedback
+ type: object
+ goal:
+ properties:
+ string:
+ type: string
+ required:
+ - string
+ title: StrSingleInput_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: StrSingleInput_Result
+ type: object
+ required:
+ - goal
+ title: StrSingleInput
+ type: object
+ type: StrSingleInput
+ transfer:
+ feedback: {}
+ goal:
+ source: source
+ target: target
+ tip_position: tip_position
+ tip_rack: tip_rack
+ volume: volume
+ goal_default:
+ asp_flow_rates:
+ - 0.0
+ asp_vols:
+ - 0.0
+ blow_out_air_volume:
+ - 0.0
+ delays:
+ - 0
+ dis_flow_rates:
+ - 0.0
+ dis_vols:
+ - 0.0
+ is_96_well: false
+ liquid_height:
+ - 0.0
+ mix_liquid_height: 0.0
+ mix_rate: 0
+ mix_stage: ''
+ mix_times:
+ - 0
+ mix_vol: 0
+ none_keys:
+ - ''
+ offsets:
+ - x: 0.0
+ y: 0.0
+ z: 0.0
+ sources:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ spread: ''
+ targets:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ tip_racks:
+ - category: ''
+ children: []
+ config: ''
+ data: ''
+ id: ''
+ name: ''
+ parent: ''
+ pose:
+ orientation:
+ w: 1.0
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ position:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+ sample_id: ''
+ type: ''
+ touch_tip: false
+ use_channels:
+ - 0
+ handles: {}
+ placeholder_keys:
+ source: unilabos_resources
+ target: unilabos_resources
+ tip_rack: unilabos_resources
+ result:
+ success: success
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: LiquidHandlerTransfer_Feedback
+ type: object
+ goal:
+ properties:
+ asp_flow_rates:
+ items:
+ type: number
+ type: array
+ asp_vols:
+ items:
+ type: number
+ type: array
+ blow_out_air_volume:
+ items:
+ type: number
+ type: array
+ delays:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ dis_flow_rates:
+ items:
+ type: number
+ type: array
+ dis_vols:
+ items:
+ type: number
+ type: array
+ is_96_well:
+ type: boolean
+ liquid_height:
+ items:
+ type: number
+ type: array
+ mix_liquid_height:
+ type: number
+ mix_rate:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ mix_stage:
+ type: string
+ mix_times:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ mix_vol:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ none_keys:
+ items:
+ type: string
+ type: array
+ offsets:
+ items:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: offsets
+ type: object
+ type: array
+ sources:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: sources
+ type: object
+ type: array
+ spread:
+ type: string
+ targets:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: targets
+ type: object
+ type: array
+ tip_racks:
+ items:
+ properties:
+ category:
+ type: string
+ children:
+ items:
+ type: string
+ type: array
+ config:
+ type: string
+ data:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ parent:
+ type: string
+ pose:
+ properties:
+ orientation:
+ properties:
+ w:
+ type: number
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ - w
+ title: orientation
+ type: object
+ position:
+ properties:
+ x:
+ type: number
+ y:
+ type: number
+ z:
+ type: number
+ required:
+ - x
+ - y
+ - z
+ title: position
+ type: object
+ required:
+ - position
+ - orientation
+ title: pose
+ type: object
+ sample_id:
+ type: string
+ type:
+ type: string
+ required:
+ - id
+ - name
+ - sample_id
+ - children
+ - parent
+ - type
+ - category
+ - pose
+ - config
+ - data
+ title: tip_racks
+ type: object
+ type: array
+ touch_tip:
+ type: boolean
+ use_channels:
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
+ required:
+ - asp_vols
+ - dis_vols
+ - sources
+ - targets
+ - tip_racks
+ - use_channels
+ - asp_flow_rates
+ - dis_flow_rates
+ - offsets
+ - touch_tip
+ - liquid_height
+ - blow_out_air_volume
+ - spread
+ - is_96_well
+ - mix_stage
+ - mix_times
+ - mix_vol
+ - mix_rate
+ - mix_liquid_height
+ - delays
+ - none_keys
+ title: LiquidHandlerTransfer_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: LiquidHandlerTransfer_Result
+ type: object
+ required:
+ - goal
+ title: LiquidHandlerTransfer
+ type: object
+ type: LiquidHandlerTransfer
+ module: unilabos.devices.laiyu_liquid.core.LaiYu_Liquid:LaiYuLiquid
+ status_types:
+ current_position: list
+ current_volume: float
+ is_connected: bool
+ is_initialized: bool
+ tip_attached: bool
+ type: python
+ config_info:
+ - default: /dev/cu.usbserial-3130
+ description: RS485转USB端口
+ name: port
+ type: string
+ - default: 1
+ description: 设备地址
+ name: address
+ type: integer
+ - default: 9600
+ description: 波特率
+ name: baudrate
+ type: integer
+ - default: 5.0
+ description: 通信超时时间 (秒)
+ name: timeout
+ type: number
+ - default: 340.0
+ description: 工作台宽度 (mm)
+ name: deck_width
+ type: number
+ - default: 250.0
+ description: 工作台高度 (mm)
+ name: deck_height
+ type: number
+ - default: 160.0
+ description: 工作台深度 (mm)
+ name: deck_depth
+ type: number
+ - default: 77000.0
+ description: 最大体积 (μL)
+ name: max_volume
+ type: number
+ - default: 0.1
+ description: 最小体积 (μL)
+ name: min_volume
+ type: number
+ - default: 100.0
+ description: 最大速度 (mm/s)
+ name: max_speed
+ type: number
+ - default: 50.0
+ description: 加速度 (mm/s²)
+ name: acceleration
+ type: number
+ - default: 50.0
+ description: 安全高度 (mm)
+ name: safe_height
+ type: number
+ - default: 10.0
+ description: 吸头拾取深度 (mm)
+ name: tip_pickup_depth
+ type: number
+ - default: true
+ description: 液面检测功能
+ name: liquid_detection
+ type: boolean
+ - default: 10.0
+ description: X轴最小安全边距 (mm)
+ name: safety_margin_x_min
+ type: number
+ - default: 10.0
+ description: X轴最大安全边距 (mm)
+ name: safety_margin_x_max
+ type: number
+ - default: 10.0
+ description: Y轴最小安全边距 (mm)
+ name: safety_margin_y_min
+ type: number
+ - default: 10.0
+ description: Y轴最大安全边距 (mm)
+ name: safety_margin_y_max
+ type: number
+ - default: 20.0
+ description: Z轴安全间隙 (mm)
+ name: safety_margin_z_clearance
+ type: number
+ description: LaiYu液体处理工作站,基于RS485通信协议的自动化液体处理设备。集成XYZ三轴运动平台和SOPA气动式移液器,支持精确的液体分配和转移操作。具备完整的硬件控制、资源管理和标准化接口,适用于实验室自动化液体处理、样品制备、试剂分配等应用场景。设备通过RS485总线控制步进电机和移液器,提供高精度的位置控制和液体处理能力。
+ handles: []
+ icon: ''
+ init_param_schema:
+ config:
+ properties:
+ acceleration:
+ default: 50.0
+ description: 加速度
+ type: number
+ address:
+ default: 1
+ description: 设备地址
+ type: integer
+ baudrate:
+ default: 9600
+ description: 波特率
+ type: integer
+ deck_depth:
+ default: 160.0
+ description: 工作台深度
+ type: number
+ deck_height:
+ default: 250.0
+ description: 工作台高度
+ type: number
+ deck_width:
+ default: 340.0
+ description: 工作台宽度
+ type: number
+ liquid_detection:
+ default: true
+ description: 液面检测功能
+ type: boolean
+ max_speed:
+ default: 100.0
+ description: 最大速度
+ type: number
+ max_volume:
+ default: 77000.0
+ description: 最大体积
+ type: number
+ min_volume:
+ default: 0.1
+ description: 最小体积
+ type: number
+ port:
+ default: /dev/cu.usbserial-3130
+ description: RS485转USB端口
+ type: string
+ safe_height:
+ default: 50.0
+ description: 安全高度
+ type: number
+ safety_margin_x_max:
+ default: 10.0
+ description: X轴最大安全边距
+ type: number
+ safety_margin_x_min:
+ default: 10.0
+ description: X轴最小安全边距
+ type: number
+ safety_margin_y_max:
+ default: 10.0
+ description: Y轴最大安全边距
+ type: number
+ safety_margin_y_min:
+ default: 10.0
+ description: Y轴最小安全边距
+ type: number
+ safety_margin_z_clearance:
+ default: 20.0
+ description: Z轴安全间隙
+ type: number
+ timeout:
+ default: 5.0
+ description: 通信超时时间
+ type: number
+ tip_pickup_depth:
+ default: 10.0
+ description: 吸头拾取深度
+ type: number
+ required:
+ - port
+ type: object
+ data:
+ properties:
+ current_position:
+ description: 当前XYZ位置
+ items:
+ type: number
+ type: array
+ current_volume:
+ description: 当前液体体积
+ type: number
+ is_connected:
+ description: 设备连接状态
+ type: boolean
+ is_initialized:
+ description: 设备初始化状态
+ type: boolean
+ tip_attached:
+ description: 是否装载吸头
+ type: boolean
+ required:
+ - current_position
+ - tip_attached
+ - current_volume
+ - is_connected
+ - is_initialized
+ type: object
+ model:
+ mesh: laiyu_liquid_handler
+ type: device
+ version: 1.0.0
\ No newline at end of file
diff --git a/unilabos/registry/resources/common/resource_container.yaml b/unilabos/registry/resources/common/resource_container.yaml
index a0b129ec..9f62fa56 100644
--- a/unilabos/registry/resources/common/resource_container.yaml
+++ b/unilabos/registry/resources/common/resource_container.yaml
@@ -72,3 +72,94 @@ tiprack_96_high:
type: resource
registry_type: resource
version: 1.0.0
+
+# 抽象资源类型定义
+tip_rack:
+ category:
+ - tip_rack
+ - labware
+ class:
+ module: unilabos.resources.tip_rack:TipRack
+ type: unilabos
+ description: 枪头架资源,用于存放和管理移液器枪头
+ handles:
+ - data_key: tip_access
+ data_source: handle
+ data_type: mechanical
+ handler_key: access
+ io_type: target
+ label: access
+ side: NORTH
+ - data_key: tip_pickup
+ data_source: handle
+ data_type: mechanical
+ handler_key: pickup
+ io_type: target
+ label: pickup
+ side: SOUTH
+ registry_type: resource
+ version: 1.0.0
+
+plate:
+ category:
+ - plate
+ - labware
+ class:
+ module: unilabos.resources.plate:Plate
+ type: unilabos
+ description: 实验板,用于放置样品和试剂
+ handles:
+ - data_key: plate_access
+ data_source: handle
+ data_type: mechanical
+ handler_key: access
+ io_type: target
+ label: access
+ side: NORTH
+ - data_key: sample_wells
+ data_source: handle
+ data_type: fluid
+ handler_key: wells
+ io_type: target
+ label: wells
+ side: CENTER
+ registry_type: resource
+ version: 1.0.0
+
+maintenance:
+ category:
+ - maintenance
+ - position
+ class:
+ module: unilabos.resources.maintenance:Maintenance
+ type: unilabos
+ description: 维护位置,用于设备维护和校准
+ handles:
+ - data_key: maintenance_access
+ data_source: handle
+ data_type: mechanical
+ handler_key: access
+ io_type: target
+ label: access
+ side: NORTH
+ registry_type: resource
+ version: 1.0.0
+
+disposal:
+ category:
+ - disposal
+ - waste
+ class:
+ module: unilabos.resources.disposal:Disposal
+ type: unilabos
+ description: 废料处理位置,用于处理实验废料
+ handles:
+ - data_key: disposal_access
+ data_source: handle
+ data_type: fluid
+ handler_key: access
+ io_type: target
+ label: access
+ side: NORTH
+ registry_type: resource
+ version: 1.0.0
diff --git a/unilabos/resources/plr_additional_res_reg.py b/unilabos/resources/plr_additional_res_reg.py
index 2b482d22..11532e72 100644
--- a/unilabos/resources/plr_additional_res_reg.py
+++ b/unilabos/resources/plr_additional_res_reg.py
@@ -7,3 +7,5 @@ def register():
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Container
# noinspection PyUnresolvedReferences
from unilabos.devices.workstation.workstation_base import WorkStationContainer
+
+ from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend