mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-27 17:59:54 +00:00
feat: Implement Laiyu liquid handling station with enhanced device control, testing, and documentation.
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
LaiYu液体处理设备后端模块
|
LaiYu液体处理设备后端模块
|
||||||
|
|
||||||
提供设备后端接口和实现
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend
|
||||||
|
|
||||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
__all__ = ['UniLiquidHandlerLaiyuBackend']
|
||||||
|
|||||||
@@ -1,334 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,385 +1,307 @@
|
|||||||
|
"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式
|
||||||
|
|
||||||
import json
|
硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致:
|
||||||
|
1. XYZController(auto_connect=True) — 先开串口
|
||||||
|
2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁
|
||||||
|
3. home_all_axes() + pipette.initialize()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from pylabrobot.liquid_handling.backends.backend import (
|
from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend
|
||||||
LiquidHandlerBackend,
|
|
||||||
)
|
|
||||||
from pylabrobot.liquid_handling.standard import (
|
from pylabrobot.liquid_handling.standard import (
|
||||||
Drop,
|
Drop,
|
||||||
DropTipRack,
|
DropTipRack,
|
||||||
MultiHeadAspirationContainer,
|
MultiHeadAspirationContainer,
|
||||||
MultiHeadAspirationPlate,
|
MultiHeadAspirationPlate,
|
||||||
MultiHeadDispenseContainer,
|
MultiHeadDispenseContainer,
|
||||||
MultiHeadDispensePlate,
|
MultiHeadDispensePlate,
|
||||||
Pickup,
|
Pickup,
|
||||||
PickupTipRack,
|
PickupTipRack,
|
||||||
ResourceDrop,
|
ResourceDrop,
|
||||||
ResourceMove,
|
ResourceMove,
|
||||||
ResourcePickup,
|
ResourcePickup,
|
||||||
SingleChannelAspiration,
|
SingleChannelAspiration,
|
||||||
SingleChannelDispense,
|
SingleChannelDispense,
|
||||||
)
|
)
|
||||||
from pylabrobot.resources import Resource, Tip
|
from pylabrobot.resources import Resource, Tip
|
||||||
|
|
||||||
import rclpy
|
from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController
|
||||||
from rclpy.node import Node
|
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import (
|
||||||
from sensor_msgs.msg import JointState
|
PipetteController,
|
||||||
import time
|
TipStatus,
|
||||||
from rclpy.action import ActionClient
|
)
|
||||||
from unilabos_msgs.action import SendCmd
|
|
||||||
import re
|
|
||||||
|
|
||||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
|
logger = logging.getLogger(__name__)
|
||||||
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
|
|
||||||
|
|
||||||
|
|
||||||
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||||
"""Chatter box backend for device-free testing. Prints out all operations."""
|
"""LaiYu 硬件后端 — PLR Backend 接口实现"""
|
||||||
|
|
||||||
_pip_length = 5
|
def __init__(
|
||||||
_vol_length = 8
|
self,
|
||||||
_resource_length = 20
|
num_channels: int = 1,
|
||||||
_offset_length = 16
|
tip_length: float = 0,
|
||||||
_flow_rate_length = 10
|
total_height: float = 310,
|
||||||
_blowout_length = 10
|
port: str = "/dev/ttyUSB0",
|
||||||
_lld_z_length = 10
|
baudrate: int = 115200,
|
||||||
_kwargs_length = 15
|
pipette_address: int = 4,
|
||||||
_tip_type_length = 12
|
):
|
||||||
_max_volume_length = 16
|
super().__init__()
|
||||||
_fitting_depth_length = 20
|
self._num_channels = num_channels
|
||||||
_tip_length_length = 16
|
self.tip_length = tip_length
|
||||||
# _pickup_method_length = 20
|
self.total_height = total_height
|
||||||
_filter_length = 10
|
|
||||||
|
|
||||||
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
|
# 保存配置,延迟到 setup() 再创建硬件对象
|
||||||
"""Initialize a chatter box backend."""
|
self._port = port
|
||||||
super().__init__()
|
self._baudrate = baudrate
|
||||||
self._num_channels = num_channels
|
self._pipette_address = pipette_address
|
||||||
self.tip_length = tip_length
|
|
||||||
self.total_height = total_height
|
|
||||||
# rclpy.init()
|
|
||||||
if not rclpy.ok():
|
|
||||||
rclpy.init()
|
|
||||||
self.joint_state_publisher = None
|
|
||||||
self.hardware_interface = PipetteController(port=port)
|
|
||||||
|
|
||||||
async def setup(self):
|
self._xyz: Optional[XYZController] = None
|
||||||
# self.joint_state_publisher = JointStatePublisher()
|
self._pipette_ctrl: Optional[PipetteController] = None
|
||||||
# self.hardware_interface.xyz_controller.connect_device()
|
self._ros_node = None
|
||||||
# self.hardware_interface.xyz_controller.home_all_axes()
|
|
||||||
await super().setup()
|
|
||||||
self.hardware_interface.connect()
|
|
||||||
self.hardware_interface.initialize()
|
|
||||||
|
|
||||||
print("Setting up the liquid handler.")
|
# ------------------------------------------------------------------ lifecycle
|
||||||
|
|
||||||
async def stop(self):
|
def post_init(self, ros_node):
|
||||||
print("Stopping the liquid handler.")
|
"""接收 ROS 节点引用(由 Handler.post_init 调用)"""
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
async def setup(self):
|
||||||
return {**super().serialize(), "num_channels": self.num_channels}
|
"""按路径 B 顺序初始化硬件"""
|
||||||
|
await super().setup()
|
||||||
|
|
||||||
def pipette_aspirate(self, volume: float, flow_rate: float):
|
# 1. XYZ 先开串口
|
||||||
|
self._xyz = XYZController(
|
||||||
|
port=self._port,
|
||||||
|
baudrate=self._baudrate,
|
||||||
|
auto_connect=True,
|
||||||
|
)
|
||||||
|
if not self._xyz.is_connected:
|
||||||
|
raise RuntimeError("XYZ 控制器连接失败")
|
||||||
|
|
||||||
self.hardware_interface.pipette.set_max_speed(flow_rate)
|
# 2. PipetteController 共享 XYZ 串口
|
||||||
res = self.hardware_interface.pipette.aspirate(volume=volume)
|
self._pipette_ctrl = PipetteController(
|
||||||
|
port=self._port,
|
||||||
|
address=self._pipette_address,
|
||||||
|
)
|
||||||
|
self._pipette_ctrl.connect_shared(
|
||||||
|
serial_conn=self._xyz.serial_conn,
|
||||||
|
serial_lock=self._xyz.serial_lock,
|
||||||
|
xyz_controller=self._xyz,
|
||||||
|
)
|
||||||
|
|
||||||
if not res:
|
# 3. 回零 + 移液器初始化
|
||||||
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
|
self._xyz.home_all_axes()
|
||||||
return
|
self._pipette_ctrl.initialize()
|
||||||
|
|
||||||
self.hardware_interface.current_volume += volume
|
logger.info("LaiYu 后端硬件初始化完成")
|
||||||
|
|
||||||
def pipette_dispense(self, volume: float, flow_rate: float):
|
async def stop(self):
|
||||||
|
"""正确断开硬件"""
|
||||||
|
try:
|
||||||
|
if self._pipette_ctrl:
|
||||||
|
self._pipette_ctrl.disconnect_shared()
|
||||||
|
if self._xyz:
|
||||||
|
self._xyz.disconnect()
|
||||||
|
logger.info("LaiYu 后端硬件已断开")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"停止后端失败: {e}")
|
||||||
|
|
||||||
self.hardware_interface.pipette.set_max_speed(flow_rate)
|
# ------------------------------------------------------------------ helpers
|
||||||
res = self.hardware_interface.pipette.dispense(volume=volume)
|
|
||||||
if not res:
|
|
||||||
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
|
|
||||||
return
|
|
||||||
self.hardware_interface.current_volume -= volume
|
|
||||||
|
|
||||||
@property
|
def _plr_to_machine_coords(self, resource, offset):
|
||||||
def num_channels(self) -> int:
|
"""PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)"""
|
||||||
return self._num_channels
|
coordinate = resource.get_absolute_location(x="c", y="c")
|
||||||
|
x = coordinate.x + offset.x
|
||||||
|
y = coordinate.y + offset.y
|
||||||
|
z_plr = coordinate.z + offset.z
|
||||||
|
return x, -y, self.total_height - (z_plr + self.tip_length)
|
||||||
|
|
||||||
async def assigned_resource_callback(self, resource: Resource):
|
def _pipette_aspirate(self, volume: float, flow_rate: float):
|
||||||
print(f"Resource {resource.name} was assigned to the liquid handler.")
|
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
|
||||||
|
res = self._pipette_ctrl.pipette.aspirate(volume=volume)
|
||||||
|
if not res:
|
||||||
|
logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}")
|
||||||
|
return
|
||||||
|
self._pipette_ctrl.current_volume += volume
|
||||||
|
|
||||||
async def unassigned_resource_callback(self, name: str):
|
def _pipette_dispense(self, volume: float, flow_rate: float):
|
||||||
print(f"Resource {name} was unassigned from the liquid handler.")
|
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
|
||||||
|
res = self._pipette_ctrl.pipette.dispense(volume=volume)
|
||||||
|
if not res:
|
||||||
|
logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}")
|
||||||
|
return
|
||||||
|
self._pipette_ctrl.current_volume -= volume
|
||||||
|
|
||||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
# ------------------------------------------------------------------ properties
|
||||||
print("Picking up tips:")
|
|
||||||
# print(ops.tip)
|
|
||||||
header = (
|
|
||||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
|
||||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
|
||||||
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
|
||||||
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
|
||||||
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
|
||||||
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
|
||||||
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
|
||||||
)
|
|
||||||
# print(header)
|
|
||||||
|
|
||||||
for op, channel in zip(ops, use_channels):
|
def serialize(self) -> dict:
|
||||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
return {**super().serialize(), "num_channels": self.num_channels}
|
||||||
row = (
|
|
||||||
f" p{channel}: "
|
|
||||||
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
|
||||||
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
|
||||||
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
|
||||||
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
|
||||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
|
||||||
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
|
||||||
)
|
|
||||||
# print(row)
|
|
||||||
# print(op.resource.get_absolute_location())
|
|
||||||
|
|
||||||
self.tip_length = ops[0].tip.total_tip_length
|
@property
|
||||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
def num_channels(self) -> int:
|
||||||
offset_xyz = ops[0].offset
|
return self._num_channels
|
||||||
x = coordinate.x + offset_xyz.x
|
|
||||||
y = coordinate.y + offset_xyz.y
|
|
||||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
|
||||||
# print("moving")
|
|
||||||
self.hardware_interface._update_tip_status()
|
|
||||||
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
|
||||||
print("已有枪头,无需重复拾取")
|
|
||||||
return
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
|
|
||||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
|
|
||||||
# goback()
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ resource callbacks
|
||||||
|
|
||||||
|
async def assigned_resource_callback(self, resource: Resource):
|
||||||
|
logger.info(f"Resource {resource.name} was assigned to the liquid handler.")
|
||||||
|
|
||||||
|
async def unassigned_resource_callback(self, name: str):
|
||||||
|
logger.info(f"Resource {name} was unassigned from the liquid handler.")
|
||||||
|
|
||||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
# ------------------------------------------------------------------ pick_up_tips
|
||||||
print("Dropping tips:")
|
|
||||||
header = (
|
|
||||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
|
||||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
|
||||||
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
|
||||||
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
|
||||||
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
|
||||||
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
|
||||||
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
|
||||||
)
|
|
||||||
# print(header)
|
|
||||||
|
|
||||||
for op, channel in zip(ops, use_channels):
|
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
tip = ops[0].tip
|
||||||
row = (
|
self.tip_length = tip.total_tip_length
|
||||||
f" p{channel}: "
|
x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||||
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
|
||||||
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
|
||||||
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
|
||||||
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
|
||||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
|
||||||
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
|
||||||
)
|
|
||||||
# print(row)
|
|
||||||
|
|
||||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
self._pipette_ctrl._update_tip_status()
|
||||||
offset_xyz = ops[0].offset
|
if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED:
|
||||||
x = coordinate.x + offset_xyz.x
|
logger.warning("已有枪头,无需重复拾取")
|
||||||
y = coordinate.y + offset_xyz.y
|
return
|
||||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
|
|
||||||
# print(x, y, z)
|
|
||||||
# print("moving")
|
|
||||||
self.hardware_interface._update_tip_status()
|
|
||||||
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
|
|
||||||
print("无枪头,无需丢弃")
|
|
||||||
return
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
|
||||||
self.hardware_interface.eject_tip
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
|
||||||
|
|
||||||
async def aspirate(
|
try:
|
||||||
self,
|
# 1. 移到枪头正上方
|
||||||
ops: List[SingleChannelAspiration],
|
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200)
|
||||||
use_channels: List[int],
|
# 2. 下压到套枪头深度(fitting_depth 是枪头套入长度)
|
||||||
**backend_kwargs,
|
z_pickup = z_top + tip.fitting_depth
|
||||||
):
|
self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100)
|
||||||
print("Aspirating:")
|
# 3. 退回安全高度
|
||||||
header = (
|
self._xyz.move_to_work_coord_safe(
|
||||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
z=self._xyz.machine_config.safe_z_height, speed=100
|
||||||
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
)
|
||||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
except Exception as e:
|
||||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
logger.error(f"pick_up_tips 移动失败: {e}")
|
||||||
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
raise
|
||||||
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
|
||||||
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
|
||||||
# f"{'liquids':<20}" # TODO: add liquids
|
|
||||||
)
|
|
||||||
for key in backend_kwargs:
|
|
||||||
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
|
|
||||||
# print(header)
|
|
||||||
|
|
||||||
for o, p in zip(ops, use_channels):
|
# ------------------------------------------------------------------ drop_tips
|
||||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
|
||||||
row = (
|
|
||||||
f" p{p}: "
|
|
||||||
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
|
||||||
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
|
||||||
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
|
||||||
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
|
||||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
|
||||||
)
|
|
||||||
for key, value in backend_kwargs.items():
|
|
||||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
|
||||||
value = "".join("T" if v else "F" for v in value)
|
|
||||||
if isinstance(value, list):
|
|
||||||
value = "".join(map(str, value))
|
|
||||||
row += f" {value:<15}"
|
|
||||||
# print(row)
|
|
||||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
|
||||||
offset_xyz = ops[0].offset
|
|
||||||
x = coordinate.x + offset_xyz.x
|
|
||||||
y = coordinate.y + offset_xyz.y
|
|
||||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
|
||||||
# print(x, y, z)
|
|
||||||
# print("moving")
|
|
||||||
|
|
||||||
# 判断枪头是否存在
|
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||||
self.hardware_interface._update_tip_status()
|
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||||
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
z -= 20 # 额外下移补偿
|
||||||
print("无枪头,无法吸液")
|
|
||||||
return
|
|
||||||
# 判断吸液量是否超过枪头容量
|
|
||||||
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
|
|
||||||
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
|
|
||||||
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
|
|
||||||
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 移动到吸液位置
|
self._pipette_ctrl._update_tip_status()
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
if self._pipette_ctrl.tip_status == TipStatus.NO_TIP:
|
||||||
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
logger.warning("无枪头,无需丢弃")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||||
|
self._pipette_ctrl.eject_tip() # 修复: 原来缺少 ()
|
||||||
|
self._xyz.move_to_work_coord_safe(
|
||||||
|
z=self._xyz.machine_config.safe_z_height
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"drop_tips 失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
# ------------------------------------------------------------------ aspirate
|
||||||
if blow_out_air_volume >0:
|
|
||||||
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
|
|
||||||
|
|
||||||
|
async def aspirate(
|
||||||
|
self,
|
||||||
|
ops: List[SingleChannelAspiration],
|
||||||
|
use_channels: List[int],
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||||
|
|
||||||
|
self._pipette_ctrl._update_tip_status()
|
||||||
|
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
|
||||||
|
raise RuntimeError("无枪头,无法吸液")
|
||||||
|
|
||||||
|
flow_rate = backend_kwargs.get("flow_rate", 500)
|
||||||
|
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
|
||||||
|
|
||||||
async def dispense(
|
if (
|
||||||
self,
|
self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume
|
||||||
ops: List[SingleChannelDispense],
|
> self._pipette_ctrl.max_volume
|
||||||
use_channels: List[int],
|
):
|
||||||
**backend_kwargs,
|
raise RuntimeError(
|
||||||
):
|
f"吸液量超过枪头容量: "
|
||||||
# print("Dispensing:")
|
f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}"
|
||||||
header = (
|
)
|
||||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
|
||||||
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
|
||||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
|
||||||
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
|
||||||
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
|
||||||
# f"{'liquids':<20}" # TODO: add liquids
|
|
||||||
)
|
|
||||||
for key in backend_kwargs:
|
|
||||||
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
|
|
||||||
# print(header)
|
|
||||||
|
|
||||||
for o, p in zip(ops, use_channels):
|
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
||||||
row = (
|
|
||||||
f" p{p}: "
|
|
||||||
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
|
||||||
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
|
||||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
|
||||||
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
|
||||||
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
|
||||||
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
|
||||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
|
||||||
)
|
|
||||||
for key, value in backend_kwargs.items():
|
|
||||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
|
||||||
value = "".join("T" if v else "F" for v in value)
|
|
||||||
if isinstance(value, list):
|
|
||||||
value = "".join(map(str, value))
|
|
||||||
row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
|
|
||||||
# print(row)
|
|
||||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
|
||||||
offset_xyz = ops[0].offset
|
|
||||||
x = coordinate.x + offset_xyz.x
|
|
||||||
y = coordinate.y + offset_xyz.y
|
|
||||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
|
||||||
# print(x, y, z)
|
|
||||||
# print("moving")
|
|
||||||
|
|
||||||
# 判断枪头是否存在
|
self._xyz.move_to_work_coord_safe(
|
||||||
self.hardware_interface._update_tip_status()
|
z=self._xyz.machine_config.safe_z_height
|
||||||
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
)
|
||||||
print("无枪头,无法排液")
|
if blow_out_air_volume > 0:
|
||||||
return
|
self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||||
# 判断排液量是否超过枪头容量
|
|
||||||
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
|
|
||||||
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
|
|
||||||
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
|
|
||||||
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ dispense
|
||||||
|
|
||||||
# 移动到排液位置
|
async def dispense(
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
self,
|
||||||
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
ops: List[SingleChannelDispense],
|
||||||
|
use_channels: List[int],
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
|
||||||
|
|
||||||
|
self._pipette_ctrl._update_tip_status()
|
||||||
|
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
|
||||||
|
raise RuntimeError("无枪头,无法排液")
|
||||||
|
|
||||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
flow_rate = backend_kwargs.get("flow_rate", 500)
|
||||||
if blow_out_air_volume > 0:
|
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
|
||||||
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
|
|
||||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
|
|
||||||
|
|
||||||
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
if (
|
||||||
print(f"Picking up tips from {pickup.resource.name}.")
|
self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"排液量超过当前体积: "
|
||||||
|
f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0"
|
||||||
|
)
|
||||||
|
|
||||||
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
|
||||||
print(f"Dropping tips to {drop.resource.name}.")
|
self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
||||||
|
|
||||||
async def aspirate96(
|
self._xyz.move_to_work_coord_safe(
|
||||||
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
z=self._xyz.machine_config.safe_z_height
|
||||||
):
|
)
|
||||||
if isinstance(aspiration, MultiHeadAspirationPlate):
|
if blow_out_air_volume > 0:
|
||||||
resource = aspiration.wells[0].parent
|
self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||||
else:
|
|
||||||
resource = aspiration.container
|
|
||||||
print(f"Aspirating {aspiration.volume} from {resource}.")
|
|
||||||
|
|
||||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
# ------------------------------------------------------------------ 96-channel stubs
|
||||||
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):
|
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||||
print(f"Picking up resource: {pickup}")
|
logger.info(f"Picking up tips from {pickup.resource.name}.")
|
||||||
|
|
||||||
async def move_picked_up_resource(self, move: ResourceMove):
|
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||||
print(f"Moving picked up resource: {move}")
|
logger.info(f"Dropping tips to {drop.resource.name}.")
|
||||||
|
|
||||||
async def drop_resource(self, drop: ResourceDrop):
|
async def aspirate96(
|
||||||
print(f"Dropping resource: {drop}")
|
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||||
|
):
|
||||||
|
if isinstance(aspiration, MultiHeadAspirationPlate):
|
||||||
|
resource = aspiration.wells[0].parent
|
||||||
|
else:
|
||||||
|
resource = aspiration.container
|
||||||
|
logger.info(f"Aspirating {aspiration.volume} from {resource}.")
|
||||||
|
|
||||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
async def dispense96(
|
||||||
return True
|
self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]
|
||||||
|
):
|
||||||
|
if isinstance(dispense, MultiHeadDispensePlate):
|
||||||
|
resource = dispense.wells[0].parent
|
||||||
|
else:
|
||||||
|
resource = dispense.container
|
||||||
|
logger.info(f"Dispensing {dispense.volume} to {resource}.")
|
||||||
|
|
||||||
|
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||||
|
logger.info(f"Picking up resource: {pickup}")
|
||||||
|
|
||||||
|
async def move_picked_up_resource(self, move: ResourceMove):
|
||||||
|
logger.info(f"Moving picked up resource: {move}")
|
||||||
|
|
||||||
|
async def drop_resource(self, drop: ResourceDrop):
|
||||||
|
logger.info(f"Dropping resource: {drop}")
|
||||||
|
|
||||||
|
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||||
|
return True
|
||||||
|
|||||||
@@ -5,21 +5,16 @@
|
|||||||
封装SOPA移液器的高级控制功能
|
封装SOPA移液器的高级控制功能
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 添加项目根目录到Python路径以解决模块导入问题
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from tkinter import N
|
|
||||||
|
_current_file = os.path.abspath(__file__)
|
||||||
|
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file)))))
|
||||||
|
if _project_root not in sys.path:
|
||||||
|
sys.path.insert(0, _project_root)
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
|
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
|
||||||
|
|
||||||
# 无论如何都添加项目根目录到路径
|
|
||||||
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 time
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict, Tuple
|
from typing import Optional, List, Dict, Tuple
|
||||||
@@ -172,24 +167,62 @@ class PipetteController:
|
|||||||
try:
|
try:
|
||||||
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
|
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
|
||||||
self.xyz_controller.serial_conn = self.pipette.serial_port
|
self.xyz_controller.serial_conn = self.pipette.serial_port
|
||||||
|
self.xyz_controller.serial_lock = self.pipette.lock
|
||||||
self.xyz_controller.is_connected = True
|
self.xyz_controller.is_connected = True
|
||||||
|
logger.info("XYZ控制器与移液器共享串口和互斥锁")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info("未配置XYZ步进电机端口,跳过运动控制器连接")
|
logger.warning(f"共享端口 XYZ 控制器创建失败: {e}")
|
||||||
|
self.xyz_controller = None
|
||||||
|
self.xyz_connected = False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"设备连接失败: {e}")
|
logger.error(f"设备连接失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool:
|
||||||
|
"""使用已连接的串口和XYZ控制器(路径 B 模式:XYZ 先开串口,移液器共享)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serial_conn: 已打开的串口连接(来自 XYZController)
|
||||||
|
serial_lock: 串口互斥锁(来自 XYZController)
|
||||||
|
xyz_controller: 已连接的 XYZController 实例
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.pipette.serial_port = serial_conn
|
||||||
|
self.pipette.lock = serial_lock
|
||||||
|
self.pipette.is_connected = True
|
||||||
|
|
||||||
|
self.xyz_controller = xyz_controller
|
||||||
|
self.xyz_connected = True
|
||||||
|
|
||||||
|
logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"connect_shared 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect_shared(self) -> None:
|
||||||
|
"""释放共享串口引用(与 connect_shared 对称)。
|
||||||
|
|
||||||
|
注意:不关闭串口本身,串口由 XYZController 负责关闭。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.pipette.serial_port = None
|
||||||
|
self.pipette.lock = None
|
||||||
|
self.pipette.is_connected = False
|
||||||
|
self.xyz_controller = None
|
||||||
|
self.xyz_connected = False
|
||||||
|
logger.info("移液控制器已释放共享串口引用")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"disconnect_shared 失败: {e}")
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
def initialize(self) -> bool:
|
||||||
"""初始化移液器"""
|
"""初始化移液器"""
|
||||||
try:
|
try:
|
||||||
if self.pipette.initialize():
|
if self.pipette.initialize():
|
||||||
logger.info("移液器初始化成功")
|
logger.info("移液器初始化成功")
|
||||||
# 检查枪头状态
|
|
||||||
self._update_tip_status()
|
self._update_tip_status()
|
||||||
self.xyz_controller.home_all_axes()
|
|
||||||
self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0)
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -198,19 +231,21 @@ class PipetteController:
|
|||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
"""断开连接"""
|
"""断开连接"""
|
||||||
# 断开移液器连接
|
if self.xyz_controller and self.xyz_connected:
|
||||||
|
if self.xyz_port != self.pipette_port:
|
||||||
|
try:
|
||||||
|
self.xyz_controller.disconnect()
|
||||||
|
logger.info("XYZ 步进电机已断开")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"断开 XYZ 步进电机失败: {e}")
|
||||||
|
else:
|
||||||
|
self.xyz_controller.serial_conn = None
|
||||||
|
self.xyz_connected = False
|
||||||
|
self.xyz_controller = None
|
||||||
|
|
||||||
self.pipette.disconnect()
|
self.pipette.disconnect()
|
||||||
logger.info("移液器已断开")
|
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:
|
def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
|
||||||
"""
|
"""
|
||||||
检查 XYZ 轴移动的安全性
|
检查 XYZ 轴移动的安全性
|
||||||
@@ -343,10 +378,9 @@ class PipetteController:
|
|||||||
"""
|
"""
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
# 停止移液器操作
|
|
||||||
try:
|
try:
|
||||||
if self.pipette and self.connected:
|
if self.pipette and self.pipette.is_connected:
|
||||||
# 这里可以添加移液器的紧急停止逻辑
|
self.pipette.emergency_stop()
|
||||||
logger.info("移液器紧急停止")
|
logger.info("移液器紧急停止")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"移液器紧急停止失败: {e}")
|
logger.error(f"移液器紧急停止失败: {e}")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pylabrobot.liquid_handling import (
|
|||||||
SingleChannelDispense,
|
SingleChannelDispense,
|
||||||
PickupTipRack,
|
PickupTipRack,
|
||||||
DropTipRack,
|
DropTipRack,
|
||||||
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
|
MultiHeadAspirationPlate,
|
||||||
)
|
)
|
||||||
from pylabrobot.liquid_handling.standard import (
|
from pylabrobot.liquid_handling.standard import (
|
||||||
MultiHeadAspirationContainer,
|
MultiHeadAspirationContainer,
|
||||||
@@ -41,12 +41,6 @@ class TransformXYZDeck(Deck):
|
|||||||
super().__init__(name, size_x, size_y, size_z)
|
super().__init__(name, size_x, size_y, size_z)
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
class TransformXYZBackend(LiquidHandlerBackend):
|
|
||||||
def __init__(self, name: str, host: str, port: int, timeout: float):
|
|
||||||
super().__init__()
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
|
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
|
||||||
def __init__(self, name: str, channel_num: int):
|
def __init__(self, name: str, channel_num: int):
|
||||||
@@ -86,7 +80,9 @@ class TransformXYZContainer(Plate, TipRack):
|
|||||||
class TransformXYZHandler(LiquidHandlerAbstract):
|
class TransformXYZHandler(LiquidHandlerAbstract):
|
||||||
support_touch_tip = False
|
support_touch_tip = False
|
||||||
|
|
||||||
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs):
|
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True,
|
||||||
|
serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4,
|
||||||
|
total_height: float = 310, **backend_kwargs):
|
||||||
# Handle case where deck is passed as a dict (from serialization)
|
# Handle case where deck is passed as a dict (from serialization)
|
||||||
if isinstance(deck, dict):
|
if isinstance(deck, dict):
|
||||||
# Try to create a TransformXYZDeck from the dict
|
# Try to create a TransformXYZDeck from the dict
|
||||||
@@ -102,11 +98,22 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
|
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
|
||||||
|
|
||||||
if simulator:
|
if simulator:
|
||||||
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num)
|
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num)
|
||||||
else:
|
else:
|
||||||
self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout)
|
self._unilabos_backend = UniLiquidHandlerLaiyuBackend(
|
||||||
|
num_channels=channel_num,
|
||||||
|
total_height=total_height,
|
||||||
|
port=serial_port,
|
||||||
|
baudrate=baudrate,
|
||||||
|
pipette_address=pipette_address,
|
||||||
|
)
|
||||||
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
|
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
|
||||||
|
|
||||||
|
def post_init(self, ros_node):
|
||||||
|
super().post_init(ros_node)
|
||||||
|
if hasattr(self._unilabos_backend, 'post_init'):
|
||||||
|
self._unilabos_backend.post_init(ros_node)
|
||||||
|
|
||||||
async def add_liquid(
|
async def add_liquid(
|
||||||
self,
|
self,
|
||||||
asp_vols: Union[List[float], float],
|
asp_vols: Union[List[float], float],
|
||||||
@@ -128,7 +135,25 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
mix_liquid_height: Optional[float] = None,
|
mix_liquid_height: Optional[float] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
):
|
):
|
||||||
pass
|
return await super().add_liquid(
|
||||||
|
asp_vols=asp_vols,
|
||||||
|
dis_vols=dis_vols,
|
||||||
|
reagent_sources=reagent_sources,
|
||||||
|
targets=targets,
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=flow_rates,
|
||||||
|
offsets=offsets,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
is_96_well=is_96_well,
|
||||||
|
delays=delays,
|
||||||
|
mix_time=mix_time,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
mix_rate=mix_rate,
|
||||||
|
mix_liquid_height=mix_liquid_height,
|
||||||
|
none_keys=none_keys,
|
||||||
|
)
|
||||||
|
|
||||||
async def aspirate(
|
async def aspirate(
|
||||||
self,
|
self,
|
||||||
@@ -142,7 +167,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
pass
|
return await super().aspirate(
|
||||||
|
resources=resources,
|
||||||
|
vols=vols,
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=flow_rates,
|
||||||
|
offsets=offsets,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
async def dispense(
|
async def dispense(
|
||||||
self,
|
self,
|
||||||
@@ -156,7 +191,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
pass
|
return await super().dispense(
|
||||||
|
resources=resources,
|
||||||
|
vols=vols,
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=flow_rates,
|
||||||
|
offsets=offsets,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
async def drop_tips(
|
async def drop_tips(
|
||||||
self,
|
self,
|
||||||
@@ -166,7 +211,13 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
allow_nonzero_volume: bool = False,
|
allow_nonzero_volume: bool = False,
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
pass
|
return await super().drop_tips(
|
||||||
|
tip_spots=tip_spots,
|
||||||
|
use_channels=use_channels,
|
||||||
|
offsets=offsets,
|
||||||
|
allow_nonzero_volume=allow_nonzero_volume,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
async def mix(
|
async def mix(
|
||||||
self,
|
self,
|
||||||
@@ -178,7 +229,15 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
mix_rate: Optional[float] = None,
|
mix_rate: Optional[float] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
):
|
):
|
||||||
pass
|
return await super().mix(
|
||||||
|
targets=targets,
|
||||||
|
mix_time=mix_time,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
height_to_bottom=height_to_bottom,
|
||||||
|
offsets=offsets,
|
||||||
|
mix_rate=mix_rate,
|
||||||
|
none_keys=none_keys,
|
||||||
|
)
|
||||||
|
|
||||||
async def pick_up_tips(
|
async def pick_up_tips(
|
||||||
self,
|
self,
|
||||||
@@ -187,7 +246,12 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
offsets: Optional[List[Coordinate]] = None,
|
offsets: Optional[List[Coordinate]] = None,
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
pass
|
return await super().pick_up_tips(
|
||||||
|
tip_spots=tip_spots,
|
||||||
|
use_channels=use_channels,
|
||||||
|
offsets=offsets,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
async def transfer_liquid(
|
async def transfer_liquid(
|
||||||
self,
|
self,
|
||||||
@@ -214,5 +278,26 @@ class TransformXYZHandler(LiquidHandlerAbstract):
|
|||||||
delays: Optional[List[int]] = None,
|
delays: Optional[List[int]] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
):
|
):
|
||||||
pass
|
return await super().transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=tip_racks,
|
||||||
|
use_channels=use_channels,
|
||||||
|
asp_vols=asp_vols,
|
||||||
|
dis_vols=dis_vols,
|
||||||
|
asp_flow_rates=asp_flow_rates,
|
||||||
|
dis_flow_rates=dis_flow_rates,
|
||||||
|
offsets=offsets,
|
||||||
|
touch_tip=touch_tip,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
is_96_well=is_96_well,
|
||||||
|
mix_stage=mix_stage,
|
||||||
|
mix_times=mix_times,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
mix_rate=mix_rate,
|
||||||
|
mix_liquid_height=mix_liquid_height,
|
||||||
|
delays=delays,
|
||||||
|
none_keys=none_keys,
|
||||||
|
)
|
||||||
|
|||||||
@@ -6945,7 +6945,7 @@ liquid_handler.laiyu:
|
|||||||
properties:
|
properties:
|
||||||
channel_num:
|
channel_num:
|
||||||
default: 1
|
default: 1
|
||||||
type: string
|
type: integer
|
||||||
deck:
|
deck:
|
||||||
type: object
|
type: object
|
||||||
host:
|
host:
|
||||||
@@ -6956,10 +6956,25 @@ liquid_handler.laiyu:
|
|||||||
type: integer
|
type: integer
|
||||||
simulator:
|
simulator:
|
||||||
default: true
|
default: true
|
||||||
type: string
|
type: boolean
|
||||||
timeout:
|
timeout:
|
||||||
default: 10.0
|
default: 10.0
|
||||||
type: number
|
type: number
|
||||||
|
serial_port:
|
||||||
|
default: /dev/ttyUSB0
|
||||||
|
description: 硬件串口端口(非 simulator 模式下使用)
|
||||||
|
type: string
|
||||||
|
baudrate:
|
||||||
|
default: 115200
|
||||||
|
type: integer
|
||||||
|
pipette_address:
|
||||||
|
default: 4
|
||||||
|
description: SOPA 移液器 RS485 地址
|
||||||
|
type: integer
|
||||||
|
total_height:
|
||||||
|
default: 310
|
||||||
|
description: 龙门架总高度 (mm),用于坐标转换
|
||||||
|
type: number
|
||||||
required:
|
required:
|
||||||
- deck
|
- deck
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in New Issue
Block a user