Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included.

* feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档

refactor: 重新组织LaiYu_Liquid模块目录结构
docs: 添加SOPA移液器和步进电机控制指令文档
fix: 修正设备配置中的最大体积默认值
test: 新增工作台配置测试用例
chore: 删除过时的测试脚本和配置文件

* add

* 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用

- 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py
- 更新所有相关文件中的导入引用
- 保持代码功能不变,仅改善命名一致性
- 测试确认所有导入正常工作

* 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出

- 添加 LaiYuLiquidBackend 到导入列表
- 添加 LaiYuLiquidBackend 到 __all__ 导出列表
- 确保所有主要类都可以正确导入

* 修复大小写文件夹名字
This commit is contained in:
ZiWei
2025-10-12 22:54:38 +08:00
committed by GitHub
parent 7c7f1b31c5
commit b263a7e679
36 changed files with 15862 additions and 0 deletions

View File

@@ -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