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