diff --git a/test/experiments/plr_test_converted.json b/test/experiments/plr_test_converted.json
index b3ec7053..a9e47f5f 100644
--- a/test/experiments/plr_test_converted.json
+++ b/test/experiments/plr_test_converted.json
@@ -10,24 +10,22 @@
"x": 620.6111111111111,
"y": 171,
"z": 0
- },
- "config": {
- "data": {
- "children": [
- {
- "_resource_child_name": "deck",
- "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck"
- }
- ],
- "backend": {
- "type": "LiquidHandlerRvizBackend"
- }
- }
- },
+ },
"data": {},
"children": [
"deck"
- ]
+ ],
+ "config": {
+ "deck": {
+ "_resource_child_name": "deck",
+ "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck"
+ },
+ "backend": {
+ "type": "UniLiquidHandlerRvizBackend"
+ },
+ "simulator": true
+ }
+
},
{
"id": "deck",
@@ -9650,7 +9648,7 @@
"children": [],
"parent": null,
"type": "device",
- "class": "robotic_arm.SCARA_with_slider.virtual",
+ "class": "robotic_arm.SCARA_with_slider.moveit.virtual",
"position": {
"x": -500,
"y": 1000,
diff --git a/test/experiments/test_laiyu.json b/test/experiments/test_laiyu.json
index fa439407..6b03d1ec 100644
--- a/test/experiments/test_laiyu.json
+++ b/test/experiments/test_laiyu.json
@@ -18,21 +18,21 @@
"config": {
"deck": {
"_resource_child_name": "deck",
- "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck",
+ "_resource_type": "unilabos.devices.liquid_handling.laiyu.laiyu:TransformXYZDeck",
"name": "deck"
},
"backend": {
- "type": "UniLiquidHandlerRvizBackend"
-
+ "type": "UniLiquidHandlerLaiyuBackend",
+ "port": "/dev/ttyUSB_CH340"
},
- "simulator": true,
- "total_height": 300
+ "simulator": false,
+ "total_height": 232.5
}
},
{
"id": "deck",
"name": "deck",
- "sample_id": null,
+
"children": [
"tip_rack",
"plate_well",
@@ -64,7 +64,7 @@
{
"id": "tip_rack",
"name": "tip_rack",
- "sample_id": null,
+
"children": [
"tip_rack_A1"
],
@@ -102,7 +102,7 @@
{
"id": "tip_rack_A1",
"name": "tip_rack_A1",
- "sample_id": null,
+
"children": [],
"parent": "tip_rack",
"type": "container",
@@ -144,7 +144,7 @@
{
"id": "plate_well",
"name": "plate_well",
- "sample_id": null,
+
"children": [
"plate_well_A1"
],
@@ -156,18 +156,6 @@
"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,
@@ -195,7 +183,7 @@
{
"id": "plate_well_A1",
"name": "plate_well_A1",
- "sample_id": null,
+
"children": [],
"parent": "plate_well",
"type": "device",
@@ -236,7 +224,7 @@
{
"id": "tube_rack",
"name": "tube_rack",
- "sample_id": null,
+
"children": [
"tube_rack_A1"
],
@@ -271,7 +259,7 @@
{
"id": "tube_rack_A1",
"name": "tube_rack_A1",
- "sample_id": null,
+
"children": [],
"parent": "tube_rack",
"type": "device",
@@ -315,7 +303,7 @@
{
"id": "bottle_rack",
"name": "bottle_rack",
- "sample_id": null,
+
"children": [
"bottle_rack_A1"
],
@@ -351,7 +339,7 @@
{
"id": "bottle_rack_A1",
"name": "bottle_rack_A1",
- "sample_id": null,
+
"children": [],
"parent": "bottle_rack",
"type": "device",
diff --git a/test/experiments/test_laiyu_v.json b/test/experiments/test_laiyu_v.json
new file mode 100644
index 00000000..64bedc8a
--- /dev/null
+++ b/test/experiments/test_laiyu_v.json
@@ -0,0 +1,383 @@
+{
+ "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": "unilabos.devices.liquid_handling.laiyu.laiyu:TransformXYZDeck",
+ "name": "deck"
+ },
+ "backend": {
+ "type": "UniLiquidHandlerRvizBackend"
+ },
+ "simulator": true,
+ "total_height": 300,
+ "joint_config": "TransformXYZDeck",
+ "simulate_rviz": true
+ }
+ },
+ {
+ "id": "deck",
+ "name": "deck",
+
+ "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",
+
+ "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",
+
+ "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",
+
+ "children": [
+ "plate_well_A1"
+ ],
+ "parent": "deck",
+ "type": "plate",
+ "class": "plate_96",
+ "position": {
+ "x": 161,
+ "y": 116,
+ "z": 48.5
+ },
+ "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",
+
+ "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",
+
+ "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",
+
+ "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",
+
+ "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",
+
+ "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/app/main.py b/unilabos/app/main.py
index db15e2c6..7b2773db 100644
--- a/unilabos/app/main.py
+++ b/unilabos/app/main.py
@@ -419,7 +419,23 @@ def main():
)
server_thread.start()
asyncio.set_event_loop(asyncio.new_event_loop())
- resource_visualization.start()
+ try:
+ resource_visualization.start()
+ except OSError as e:
+ if "AMENT_PREFIX_PATH" in str(e):
+ print_status(
+ f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}",
+ "warning"
+ )
+ print_status(
+ "建议解决方案:\n"
+ "1. 激活Conda环境: conda activate unilab\n"
+ "2. 或使用 --backend simple 参数\n"
+ "3. 或使用 --visual disable 参数禁用可视化",
+ "info"
+ )
+ else:
+ raise
while True:
time.sleep(1)
else:
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/default_kinematics.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/default_kinematics.yaml
new file mode 100644
index 00000000..a7c5ae6a
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/default_kinematics.yaml
@@ -0,0 +1,25 @@
+dummy2_robot:
+ kinematics:
+ # DH parameters for Dummy2 6-DOF robot arm
+ # [theta, d, a, alpha] for each joint
+ joint_1: [0.0, 0.1, 0.0, 1.5708] # Base rotation
+ joint_2: [0.0, 0.0, 0.2, 0.0] # Shoulder
+ joint_3: [0.0, 0.0, 0.15, 0.0] # Elbow
+ joint_4: [0.0, 0.1, 0.0, 1.5708] # Wrist roll
+ joint_5: [0.0, 0.0, 0.0, -1.5708] # Wrist pitch
+ joint_6: [0.0, 0.06, 0.0, 0.0] # Wrist yaw
+
+ # Tool center point offset from last joint
+ tcp_offset:
+ x: 0.0
+ y: 0.0
+ z: 0.04
+
+ # Workspace limits
+ workspace:
+ x_min: -0.5
+ x_max: 0.5
+ y_min: -0.5
+ y_max: 0.5
+ z_min: 0.0
+ z_max: 0.6
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.srdf b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.srdf
new file mode 100644
index 00000000..5b53b86f
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.srdf
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.trans b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.trans
new file mode 100644
index 00000000..edd3461d
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.trans
@@ -0,0 +1,70 @@
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.urdf.xacro b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.urdf.xacro
new file mode 100644
index 00000000..1fb0c97b
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.urdf.xacro
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/dummy2_simulated_config.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2_simulated_config.yaml
new file mode 100644
index 00000000..4856bf1a
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2_simulated_config.yaml
@@ -0,0 +1,73 @@
+###############################################
+# Modify all parameters related to servoing here
+###############################################
+# adapt to dummy2 by Muzhxiaowen, check out the details on bilibili.com
+
+use_gazebo: false # Whether the robot is started in a Gazebo simulation environment
+
+## Properties of incoming commands
+command_in_type: "unitless" # "unitless"> in the range [-1:1], as if from joystick. "speed_units"> cmds are in m/s and rad/s
+scale:
+ # Scale parameters are only used if command_in_type=="unitless"
+ linear: 0.4 # Max linear velocity. Unit is [m/s]. Only used for Cartesian commands.
+ rotational: 0.8 # Max angular velocity. Unit is [rad/s]. Only used for Cartesian commands.
+ # Max joint angular/linear velocity. Only used for joint commands on joint_command_in_topic.
+ joint: 0.5
+
+# Optionally override Servo's internal velocity scaling when near singularity or collision (0.0 = use internal velocity scaling)
+# override_velocity_scaling_factor = 0.0 # valid range [0.0:1.0]
+
+## Properties of outgoing commands
+publish_period: 0.034 # 1/Nominal publish rate [seconds]
+low_latency_mode: false # Set this to true to publish as soon as an incoming Twist command is received (publish_period is ignored)
+
+# What type of topic does your robot driver expect?
+# Currently supported are std_msgs/Float64MultiArray or trajectory_msgs/JointTrajectory
+command_out_type: trajectory_msgs/JointTrajectory
+
+# What to publish? Can save some bandwidth as most robots only require positions or velocities
+publish_joint_positions: true
+publish_joint_velocities: true
+publish_joint_accelerations: false
+
+## Plugins for smoothing outgoing commands
+smoothing_filter_plugin_name: "online_signal_smoothing::ButterworthFilterPlugin"
+
+# If is_primary_planning_scene_monitor is set to true, the Servo server's PlanningScene advertises the /get_planning_scene service,
+# which other nodes can use as a source for information about the planning environment.
+# NOTE: If a different node in your system is responsible for the "primary" planning scene instance (e.g. the MoveGroup node),
+# then is_primary_planning_scene_monitor needs to be set to false.
+is_primary_planning_scene_monitor: true
+
+## MoveIt properties
+move_group_name: dummy2_arm # Often 'manipulator' or 'arm'
+planning_frame: base_link # The MoveIt planning frame. Often 'base_link' or 'world'
+
+## Other frames
+ee_frame_name: J6_1 # The name of the end effector link, used to return the EE pose
+robot_link_command_frame: base_link # commands must be given in the frame of a robot link. Usually either the base or end effector
+
+## Stopping behaviour
+incoming_command_timeout: 0.1 # Stop servoing if X seconds elapse without a new command
+# If 0, republish commands forever even if the robot is stationary. Otherwise, specify num. to publish.
+# Important because ROS may drop some messages and we need the robot to halt reliably.
+num_outgoing_halt_msgs_to_publish: 4
+
+## Configure handling of singularities and joint limits
+lower_singularity_threshold: 170.0 # Start decelerating when the condition number hits this (close to singularity)
+hard_stop_singularity_threshold: 3000.0 # Stop when the condition number hits this
+joint_limit_margin: 0.1 # added as a buffer to joint limits [radians]. If moving quickly, make this larger.
+leaving_singularity_threshold_multiplier: 2.0 # Multiply the hard stop limit by this when leaving singularity (see https://github.com/ros-planning/moveit2/pull/620)
+
+## Topic names
+cartesian_command_in_topic: ~/delta_twist_cmds # Topic for incoming Cartesian twist commands
+joint_command_in_topic: ~/delta_joint_cmds # Topic for incoming joint angle commands
+joint_topic: /joint_states
+status_topic: ~/status # Publish status to this topic
+command_out_topic: /dummy2_arm_controller/joint_trajectory # Publish outgoing commands here
+
+## Collision checking for the entire robot body
+check_collisions: true # Check collisions?
+collision_check_rate: 10.0 # [Hz] Collision-checking can easily bog down a CPU if done too often.
+self_collision_proximity_threshold: 0.001 # Start decelerating when a self-collision is this far [m]
+scene_collision_proximity_threshold: 0.002 # Start decelerating when a scene collision is this far [m]
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/initial_positions.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/initial_positions.yaml
new file mode 100644
index 00000000..841bba05
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/initial_positions.yaml
@@ -0,0 +1,9 @@
+# Default initial positions for dummy2's ros2_control fake system
+
+initial_positions:
+ Joint1: 0
+ Joint2: 0
+ Joint3: 0
+ Joint4: 0
+ Joint5: 0
+ Joint6: 0
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/joint_limits.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/joint_limits.yaml
new file mode 100644
index 00000000..151fb300
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/joint_limits.yaml
@@ -0,0 +1,40 @@
+# joint_limits.yaml allows the dynamics properties specified in the URDF to be overwritten or augmented as needed
+
+# For beginners, we downscale velocity and acceleration limits.
+# You can always specify higher scaling factors (<= 1.0) in your motion requests. # Increase the values below to 1.0 to always move at maximum speed.
+default_velocity_scaling_factor: 0.1
+default_acceleration_scaling_factor: 0.1
+
+# Specific joint properties can be changed with the keys [max_position, min_position, max_velocity, max_acceleration]
+# Joint limits can be turned off with [has_velocity_limits, has_acceleration_limits]
+joint_limits:
+ joint_1:
+ has_velocity_limits: true
+ max_velocity: 2.0
+ has_acceleration_limits: false
+ max_acceleration: 0
+ joint_2:
+ has_velocity_limits: true
+ max_velocity: 2.0
+ has_acceleration_limits: false
+ max_acceleration: 0
+ joint_3:
+ has_velocity_limits: true
+ max_velocity: 2.0
+ has_acceleration_limits: false
+ max_acceleration: 0
+ joint_4:
+ has_velocity_limits: true
+ max_velocity: 2.0
+ has_acceleration_limits: false
+ max_acceleration: 0
+ joint_5:
+ has_velocity_limits: true
+ max_velocity: 2.0
+ has_acceleration_limits: false
+ max_acceleration: 0
+ joint_6:
+ has_velocity_limits: true
+ max_velocity: 2.0
+ has_acceleration_limits: false
+ max_acceleration: 0
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/kinematics.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/kinematics.yaml
new file mode 100644
index 00000000..55cefc6d
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/kinematics.yaml
@@ -0,0 +1,4 @@
+dummy2_arm:
+ kinematics_solver: kdl_kinematics_plugin/KDLKinematicsPlugin
+ kinematics_solver_search_resolution: 0.0050000000000000001
+ kinematics_solver_timeout: 0.5
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/macro.ros2_control.xacro b/unilabos/device_mesh/devices/dummy2_robot/config/macro.ros2_control.xacro
new file mode 100644
index 00000000..25bba6d1
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/macro.ros2_control.xacro
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+ mock_components/GenericSystem
+
+
+
+
+
+
+ ${initial_positions['Joint1']}
+
+
+
+
+
+
+ ${initial_positions['Joint2']}
+
+
+
+
+
+
+ ${initial_positions['Joint3']}
+
+
+
+
+
+
+ ${initial_positions['Joint4']}
+
+
+
+
+
+
+ ${initial_positions['Joint5']}
+
+
+
+
+
+
+ ${initial_positions['Joint6']}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/macro.srdf.xacro b/unilabos/device_mesh/devices/dummy2_robot/config/macro.srdf.xacro
new file mode 100644
index 00000000..f1265731
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/macro.srdf.xacro
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/materials.xacro b/unilabos/device_mesh/devices/dummy2_robot/config/materials.xacro
new file mode 100644
index 00000000..1e1fda33
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/materials.xacro
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/move_group.json b/unilabos/device_mesh/devices/dummy2_robot/config/move_group.json
new file mode 100644
index 00000000..e2fc0c22
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/move_group.json
@@ -0,0 +1,14 @@
+{
+ "arm": {
+ "joint_names": [
+ "joint_1",
+ "joint_2",
+ "joint_3",
+ "joint_4",
+ "joint_5",
+ "joint_6"
+ ],
+ "base_link_name": "base_link",
+ "end_effector_name": "J6_1"
+ }
+}
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/moveit.rviz b/unilabos/device_mesh/devices/dummy2_robot/config/moveit.rviz
new file mode 100644
index 00000000..99bb740f
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/moveit.rviz
@@ -0,0 +1,51 @@
+Panels:
+ - Class: rviz_common/Displays
+ Name: Displays
+ Property Tree Widget:
+ Expanded:
+ - /MotionPlanning1
+ - Class: rviz_common/Help
+ Name: Help
+ - Class: rviz_common/Views
+ Name: Views
+Visualization Manager:
+ Displays:
+ - Class: rviz_default_plugins/Grid
+ Name: Grid
+ Value: true
+ - Class: moveit_rviz_plugin/MotionPlanning
+ Name: MotionPlanning
+ Planned Path:
+ Loop Animation: true
+ State Display Time: 0.05 s
+ Trajectory Topic: display_planned_path
+ Planning Scene Topic: monitored_planning_scene
+ Robot Description: robot_description
+ Scene Geometry:
+ Scene Alpha: 1
+ Scene Robot:
+ Robot Alpha: 0.5
+ Value: true
+ Global Options:
+ Fixed Frame: base_link
+ Tools:
+ - Class: rviz_default_plugins/Interact
+ - Class: rviz_default_plugins/MoveCamera
+ - Class: rviz_default_plugins/Select
+ Value: true
+ Views:
+ Current:
+ Class: rviz_default_plugins/Orbit
+ Distance: 2.0
+ Focal Point:
+ X: -0.1
+ Y: 0.25
+ Z: 0.30
+ Name: Current View
+ Pitch: 0.5
+ Target Frame: base_link
+ Yaw: -0.623
+Window Geometry:
+ Height: 975
+ QMainWindow State: 000000ff00000000fd0000000100000000000002b400000375fc0200000005fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000004100fffffffb000000100044006900730070006c006100790073010000003d00000123000000c900fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e00670100000166000001910000018800fffffffb0000000800480065006c0070000000029a0000006e0000006e00fffffffb0000000a0056006900650077007301000002fd000000b5000000a400ffffff000001f60000037500000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730100000000ffffffff0000000000000000
+ Width: 1200
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/moveit_controllers.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/moveit_controllers.yaml
new file mode 100644
index 00000000..153ff5e6
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/moveit_controllers.yaml
@@ -0,0 +1,21 @@
+# MoveIt uses this configuration for controller management
+
+moveit_controller_manager: moveit_simple_controller_manager/MoveItSimpleControllerManager
+
+moveit_simple_controller_manager:
+ controller_names:
+ - dummy2_arm_controller
+
+ dummy2_arm_controller:
+ type: FollowJointTrajectory
+ action_ns: follow_joint_trajectory
+ default: true
+ joints:
+ - Joint1
+ - Joint2
+ - Joint3
+ - Joint4
+ - Joint5
+ - Joint6
+ action_ns: follow_joint_trajectory
+ default: true
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/physical_parameters.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/physical_parameters.yaml
new file mode 100644
index 00000000..cd6f60c8
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/physical_parameters.yaml
@@ -0,0 +1,39 @@
+dummy2_robot:
+ # Physical properties for each link
+ link_masses:
+ base_link: 5.0
+ link_1: 3.0
+ link_2: 2.5
+ link_3: 2.0
+ link_4: 1.5
+ link_5: 1.0
+ link_6: 0.5
+
+ # Center of mass for each link (relative to joint frame)
+ link_com:
+ base_link: [0.0, 0.0, 0.05]
+ link_1: [0.0, 0.0, 0.05]
+ link_2: [0.1, 0.0, 0.0]
+ link_3: [0.08, 0.0, 0.0]
+ link_4: [0.0, 0.0, 0.05]
+ link_5: [0.0, 0.0, 0.03]
+ link_6: [0.0, 0.0, 0.02]
+
+ # Moment of inertia matrices
+ link_inertias:
+ base_link: [0.02, 0.0, 0.0, 0.02, 0.0, 0.02]
+ link_1: [0.01, 0.0, 0.0, 0.01, 0.0, 0.01]
+ link_2: [0.008, 0.0, 0.0, 0.008, 0.0, 0.008]
+ link_3: [0.006, 0.0, 0.0, 0.006, 0.0, 0.006]
+ link_4: [0.004, 0.0, 0.0, 0.004, 0.0, 0.004]
+ link_5: [0.002, 0.0, 0.0, 0.002, 0.0, 0.002]
+ link_6: [0.001, 0.0, 0.0, 0.001, 0.0, 0.001]
+
+ # Motor specifications
+ motor_specs:
+ joint_1: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 }
+ joint_2: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 }
+ joint_3: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 }
+ joint_4: { max_torque: 50.0, max_speed: 2.0, gear_ratio: 50 }
+ joint_5: { max_torque: 50.0, max_speed: 2.0, gear_ratio: 50 }
+ joint_6: { max_torque: 25.0, max_speed: 2.0, gear_ratio: 25 }
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/pilz_cartesian_limits.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/pilz_cartesian_limits.yaml
new file mode 100644
index 00000000..b2997caf
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/pilz_cartesian_limits.yaml
@@ -0,0 +1,6 @@
+# Limits for the Pilz planner
+cartesian_limits:
+ max_trans_vel: 1.0
+ max_trans_acc: 2.25
+ max_trans_dec: -5.0
+ max_rot_vel: 1.57
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/ros2_controllers.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/ros2_controllers.yaml
new file mode 100644
index 00000000..6265fa4f
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/ros2_controllers.yaml
@@ -0,0 +1,26 @@
+# This config file is used by ros2_control
+controller_manager:
+ ros__parameters:
+ update_rate: 100 # Hz
+
+ dummy2_arm_controller:
+ type: joint_trajectory_controller/JointTrajectoryController
+
+
+ joint_state_broadcaster:
+ type: joint_state_broadcaster/JointStateBroadcaster
+
+dummy2_arm_controller:
+ ros__parameters:
+ joints:
+ - Joint1
+ - Joint2
+ - Joint3
+ - Joint4
+ - Joint5
+ - Joint6
+ command_interfaces:
+ - position
+ state_interfaces:
+ - position
+ - velocity
\ No newline at end of file
diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/visual_parameters.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/visual_parameters.yaml
new file mode 100644
index 00000000..e9cc6615
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/config/visual_parameters.yaml
@@ -0,0 +1,35 @@
+dummy2_robot:
+ # Visual appearance settings
+ materials:
+ base_material:
+ color: [0.8, 0.8, 0.8, 1.0] # Light gray
+ metallic: 0.1
+ roughness: 0.3
+
+ link_material:
+ color: [0.2, 0.2, 0.8, 1.0] # Blue
+ metallic: 0.3
+ roughness: 0.2
+
+ joint_material:
+ color: [0.6, 0.6, 0.6, 1.0] # Dark gray
+ metallic: 0.5
+ roughness: 0.1
+
+ camera_material:
+ color: [0.1, 0.1, 0.1, 1.0] # Black
+ metallic: 0.0
+ roughness: 0.8
+
+ # Mesh scaling factors
+ mesh_scale: [0.001, 0.001, 0.001] # Convert mm to m
+
+ # Collision geometry simplification
+ collision_geometries:
+ base_link: "cylinder" # radius: 0.08, height: 0.1
+ link_1: "cylinder" # radius: 0.05, height: 0.15
+ link_2: "box" # size: [0.2, 0.08, 0.08]
+ link_3: "box" # size: [0.15, 0.06, 0.06]
+ link_4: "cylinder" # radius: 0.03, height: 0.1
+ link_5: "cylinder" # radius: 0.025, height: 0.06
+ link_6: "cylinder" # radius: 0.02, height: 0.04
diff --git a/unilabos/device_mesh/devices/dummy2_robot/dummy2.xacro b/unilabos/device_mesh/devices/dummy2_robot/dummy2.xacro
new file mode 100644
index 00000000..f7959cbf
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/dummy2.xacro
@@ -0,0 +1,237 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/devices/dummy2_robot/joint_limit.yaml b/unilabos/device_mesh/devices/dummy2_robot/joint_limit.yaml
new file mode 100644
index 00000000..4bbb56c0
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/joint_limit.yaml
@@ -0,0 +1,37 @@
+joint_limits:
+
+ joint_1:
+ effort: 150
+ velocity: 2.0
+ lower: !degrees -180
+ upper: !degrees 180
+
+ joint_2:
+ effort: 150
+ velocity: 2.0
+ lower: !degrees -90
+ upper: !degrees 90
+
+ joint_3:
+ effort: 150
+ velocity: 2.0
+ lower: !degrees -90
+ upper: !degrees 90
+
+ joint_4:
+ effort: 50
+ velocity: 2.0
+ lower: !degrees -180
+ upper: !degrees 180
+
+ joint_5:
+ effort: 50
+ velocity: 2.0
+ lower: !degrees -90
+ upper: !degrees 90
+
+ joint_6:
+ effort: 25
+ velocity: 2.0
+ lower: !degrees -180
+ upper: !degrees 180
diff --git a/unilabos/device_mesh/devices/dummy2_robot/macro_device.xacro b/unilabos/device_mesh/devices/dummy2_robot/macro_device.xacro
new file mode 100644
index 00000000..2112dd7e
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/macro_device.xacro
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J1_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J1_1.stl
new file mode 100644
index 00000000..744ff335
Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J1_1.stl differ
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J2_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J2_1.stl
new file mode 100644
index 00000000..94b75fe9
Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J2_1.stl differ
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J3_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J3_1.stl
new file mode 100644
index 00000000..fb172d87
Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J3_1.stl differ
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J4_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J4_1.stl
new file mode 100644
index 00000000..a7e12a6b
Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J4_1.stl differ
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J5_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J5_1.stl
new file mode 100644
index 00000000..091eccc0
Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J5_1.stl differ
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J6_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J6_1.stl
new file mode 100644
index 00000000..55f51b28
Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J6_1.stl differ
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/base_link.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/base_link.stl
new file mode 100644
index 00000000..f5ded8a5
Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/base_link.stl differ
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/camera_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/camera_1.stl
new file mode 100644
index 00000000..b5a6ece0
Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/camera_1.stl differ
diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/dummy2.xacro b/unilabos/device_mesh/devices/dummy2_robot/meshes/dummy2.xacro
new file mode 100644
index 00000000..f7959cbf
--- /dev/null
+++ b/unilabos/device_mesh/devices/dummy2_robot/meshes/dummy2.xacro
@@ -0,0 +1,237 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
old mode 100644
new mode 100755
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
old mode 100644
new mode 100755
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
old mode 100644
new mode 100755
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
old mode 100644
new mode 100755
diff --git a/unilabos/device_mesh/resource_visalization.py b/unilabos/device_mesh/resource_visalization.py
index e430abd1..fc37150c 100644
--- a/unilabos/device_mesh/resource_visalization.py
+++ b/unilabos/device_mesh/resource_visalization.py
@@ -14,6 +14,7 @@ from launch_ros.parameter_descriptions import ParameterFile
from unilabos.registry.registry import lab_registry
from ament_index_python.packages import get_package_share_directory
+
def get_pattern_matches(folder, pattern):
"""Given all the files in the folder, find those that match the pattern.
@@ -51,7 +52,7 @@ class ResourceVisualization:
self.launch_description = LaunchDescription()
self.resource_dict = resource
self.resource_model = {}
- self.resource_type = ['deck', 'plate', 'container']
+ self.resource_type = ['deck', 'plate', 'container', 'tip_rack']
self.mesh_path = Path(__file__).parent.absolute()
self.enable_rviz = enable_rviz
registry = lab_registry
@@ -128,9 +129,9 @@ class ResourceVisualization:
# if node["parent"] is not None:
# new_dev.set("station_name", node["parent"]+'_')
- new_dev.set("x",str(float(node["position"]["x"])/1000))
- new_dev.set("y",str(float(node["position"]["y"])/1000))
- new_dev.set("z",str(float(node["position"]["z"])/1000))
+ new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
+ new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
+ new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
if "rotation" in node["config"]:
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
@@ -140,7 +141,7 @@ class ResourceVisualization:
new_dev.set(key, str(value))
# 添加ros2_controller
- if node['class'].startswith('moveit.'):
+ if node['class'].find('moveit.')!= -1:
new_include_controller = etree.SubElement(self.root, f"{{{xacro_uri}}}include")
new_include_controller.set("filename", f"{str(self.mesh_path)}/devices/{model_config['mesh']}/config/macro.ros2_control.xacro")
new_controller = etree.SubElement(self.root, f"{{{xacro_uri}}}{model_config['mesh']}_ros2_control")
@@ -203,7 +204,24 @@ class ResourceVisualization:
Returns:
LaunchDescription: launch描述对象
"""
- moveit_configs_utils_path = Path(get_package_share_directory("moveit_configs_utils"))
+ # 检查ROS 2环境变量
+ if "AMENT_PREFIX_PATH" not in os.environ:
+ raise OSError(
+ "ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
+ "请确保:\n"
+ "1. 已安装ROS 2 (推荐使用 ros-humble-desktop-full)\n"
+ "2. 已激活Conda环境: conda activate unilab\n"
+ "3. 或手动source ROS 2 setup文件: source /opt/ros/humble/setup.bash\n"
+ "4. 或者使用 --backend simple 参数跳过ROS依赖"
+ )
+
+ try:
+ moveit_configs_utils_path = Path(get_package_share_directory("moveit_configs_utils"))
+ except Exception as e:
+ raise OSError(
+ f"无法找到moveit_configs_utils包。请确保ROS 2和MoveIt 2已正确安装。\n"
+ f"原始错误: {e}"
+ )
default_folder = moveit_configs_utils_path / "default_configs"
planning_pattern = re.compile("^(.*)_planning.yaml$")
pipelines = []
@@ -264,7 +282,8 @@ class ResourceVisualization:
parameters=[
{"robot_description": robot_description},
ros2_controllers,
- ]
+ ],
+ env=dict(os.environ)
)
)
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
@@ -274,6 +293,7 @@ class ResourceVisualization:
executable="spawner",
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
output="screen",
+ env=dict(os.environ)
)
)
controllers.append(
@@ -282,6 +302,7 @@ class ResourceVisualization:
executable="spawner",
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
output="screen",
+ env=dict(os.environ)
)
)
for i in controllers:
@@ -300,7 +321,8 @@ class ResourceVisualization:
'use_sim_time': False
},
# kinematics_dict
- ]
+ ],
+ env=dict(os.environ)
)
@@ -331,7 +353,8 @@ class ResourceVisualization:
package='moveit_ros_move_group',
executable='move_group',
output='screen',
- parameters=moveit_params
+ parameters=moveit_params,
+ env=dict(os.environ)
)
@@ -354,7 +377,8 @@ class ResourceVisualization:
robot_description_planning,
planning_pipelines,
- ]
+ ],
+ env=dict(os.environ)
)
self.launch_description.add_action(rviz_node)
diff --git a/unilabos/device_mesh/resources/bottle/meshes/bottle.stl b/unilabos/device_mesh/resources/bottle/meshes/bottle.stl
new file mode 100644
index 00000000..6e3a1437
Binary files /dev/null and b/unilabos/device_mesh/resources/bottle/meshes/bottle.stl differ
diff --git a/unilabos/device_mesh/resources/bottle/modal.xacro b/unilabos/device_mesh/resources/bottle/modal.xacro
new file mode 100644
index 00000000..07fa88e7
--- /dev/null
+++ b/unilabos/device_mesh/resources/bottle/modal.xacro
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/resources/bottle_container/meshes/bottle_container.stl b/unilabos/device_mesh/resources/bottle_container/meshes/bottle_container.stl
new file mode 100644
index 00000000..5495e3a7
Binary files /dev/null and b/unilabos/device_mesh/resources/bottle_container/meshes/bottle_container.stl differ
diff --git a/unilabos/device_mesh/resources/bottle_container/modal.xacro b/unilabos/device_mesh/resources/bottle_container/modal.xacro
new file mode 100644
index 00000000..1de8dfe9
--- /dev/null
+++ b/unilabos/device_mesh/resources/bottle_container/modal.xacro
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/resources/plate_96/meshes/plate_96.stl b/unilabos/device_mesh/resources/plate_96/meshes/plate_96.stl
new file mode 100644
index 00000000..609df740
Binary files /dev/null and b/unilabos/device_mesh/resources/plate_96/meshes/plate_96.stl differ
diff --git a/unilabos/device_mesh/resources/plate_96/modal.xacro b/unilabos/device_mesh/resources/plate_96/modal.xacro
new file mode 100644
index 00000000..0f94dccb
--- /dev/null
+++ b/unilabos/device_mesh/resources/plate_96/modal.xacro
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/resources/tip/meshes/tip.stl b/unilabos/device_mesh/resources/tip/meshes/tip.stl
new file mode 100644
index 00000000..11c0b968
Binary files /dev/null and b/unilabos/device_mesh/resources/tip/meshes/tip.stl differ
diff --git a/unilabos/device_mesh/resources/tip/modal.xacro b/unilabos/device_mesh/resources/tip/modal.xacro
new file mode 100644
index 00000000..758ed174
--- /dev/null
+++ b/unilabos/device_mesh/resources/tip/modal.xacro
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/resources/tiprack_box/meshes/tiprack_box.stl b/unilabos/device_mesh/resources/tiprack_box/meshes/tiprack_box.stl
new file mode 100644
index 00000000..d8c52d36
Binary files /dev/null and b/unilabos/device_mesh/resources/tiprack_box/meshes/tiprack_box.stl differ
diff --git a/unilabos/device_mesh/resources/tiprack_box/modal.xacro b/unilabos/device_mesh/resources/tiprack_box/modal.xacro
new file mode 100644
index 00000000..dacb1985
--- /dev/null
+++ b/unilabos/device_mesh/resources/tiprack_box/modal.xacro
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/resources/tube/meshes/tube.stl b/unilabos/device_mesh/resources/tube/meshes/tube.stl
new file mode 100644
index 00000000..0fce2fc6
Binary files /dev/null and b/unilabos/device_mesh/resources/tube/meshes/tube.stl differ
diff --git a/unilabos/device_mesh/resources/tube/modal.xacro b/unilabos/device_mesh/resources/tube/modal.xacro
new file mode 100644
index 00000000..348d9231
--- /dev/null
+++ b/unilabos/device_mesh/resources/tube/modal.xacro
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/resources/tube_container/meshes/tube_container.stl b/unilabos/device_mesh/resources/tube_container/meshes/tube_container.stl
new file mode 100644
index 00000000..299e2ac0
Binary files /dev/null and b/unilabos/device_mesh/resources/tube_container/meshes/tube_container.stl differ
diff --git a/unilabos/device_mesh/resources/tube_container/modal.xacro b/unilabos/device_mesh/resources/tube_container/modal.xacro
new file mode 100644
index 00000000..600b368b
--- /dev/null
+++ b/unilabos/device_mesh/resources/tube_container/modal.xacro
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unilabos/device_mesh/view_robot.rviz b/unilabos/device_mesh/view_robot.rviz
index 50e0543e..25ffd2e8 100644
--- a/unilabos/device_mesh/view_robot.rviz
+++ b/unilabos/device_mesh/view_robot.rviz
@@ -5,11 +5,13 @@ Panels:
Property Tree Widget:
Expanded:
- /TF1/Tree1
+ - /PlanningScene1
+ - /PlanningScene1/Scene Geometry1
- /MotionPlanning1/Scene Geometry1
- /MotionPlanning1/Scene Robot1
- /MotionPlanning1/Planning Request1
Splitter Ratio: 0.5016146302223206
- Tree Height: 1112
+ Tree Height: 563
- Class: rviz_common/Selection
Name: Selection
- Class: rviz_common/Tool Properties
@@ -91,7 +93,7 @@ Visualization Manager:
Planning Scene Topic: /monitored_planning_scene
Robot Description: robot_description
Scene Geometry:
- Scene Alpha: 0.8999999761581421
+ Scene Alpha: 1
Scene Color: 50; 230; 50
Scene Display Time: 0.009999999776482582
Show Scene Geometry: true
@@ -567,25 +569,25 @@ Visualization Manager:
Pitch: 0.4297958016395569
Target Frame:
Value: Orbit (rviz)
- Yaw: 0.3525616228580475
+ Yaw: 0.36756160855293274
Saved: ~
Window Geometry:
Displays:
collapsed: false
- Height: 2032
+ Height: 1088
Hide Left Dock: false
Hide Right Dock: true
MotionPlanning:
- collapsed: true
+ collapsed: false
MotionPlanning - Trajectory Slider:
collapsed: false
- QMainWindow State: 000000ff00000000fd0000000400000000000003a30000079bfc020000000bfb0000001200530065006c0065006300740069006f006e00000001e10000009b000000b000fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c0061007900730100000027000004c60000018200fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb000000280020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000000000000000fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000007a00fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e006701000004f9000002c9000002b800ffffff000000010000010f00000387fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073000000003b000003870000013200fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d0065010000000000000450000000000000000000000bc50000079b00000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000
+ QMainWindow State: 000000ff00000000fd0000000400000000000003a30000040bfc020000000bfb0000001200530065006c0065006300740069006f006e00000001e10000009b0000005c00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073010000001700000271000000ca00fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb000000280020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000000000000000fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000004200fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e0067010000028e000001940000018900ffffff000000010000010f00000387fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073000000003b00000387000000a600fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d00650100000000000004500000000000000000000004110000040b00000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000
Selection:
collapsed: false
Tool Properties:
collapsed: false
Views:
collapsed: true
- Width: 3956
- X: 140
- Y: 54
+ Width: 1978
+ X: 70
+ Y: 27
diff --git a/unilabos/devices/liquid_handling/laiyu/backend/__init__.py b/unilabos/devices/liquid_handling/laiyu/backend/__init__.py
new file mode 100644
index 00000000..4bf29392
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/backend/laiyu_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py
new file mode 100644
index 00000000..5e8041c0
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/backend/laiyu_v_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py
new file mode 100644
index 00000000..d5636b25
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py
@@ -0,0 +1,385 @@
+
+import json
+from typing import List, Optional, Union
+
+from pylabrobot.liquid_handling.backends.backend import (
+ LiquidHandlerBackend,
+)
+from pylabrobot.liquid_handling.standard import (
+ Drop,
+ DropTipRack,
+ MultiHeadAspirationContainer,
+ MultiHeadAspirationPlate,
+ MultiHeadDispenseContainer,
+ MultiHeadDispensePlate,
+ Pickup,
+ PickupTipRack,
+ ResourceDrop,
+ ResourceMove,
+ ResourcePickup,
+ SingleChannelAspiration,
+ SingleChannelDispense,
+)
+from pylabrobot.resources import Resource, Tip
+
+import rclpy
+from rclpy.node import Node
+from sensor_msgs.msg import JointState
+import time
+from rclpy.action import ActionClient
+from unilabos_msgs.action import SendCmd
+import re
+
+from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
+from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
+
+
+class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
+ """Chatter box backend for device-free testing. Prints out all operations."""
+
+ _pip_length = 5
+ _vol_length = 8
+ _resource_length = 20
+ _offset_length = 16
+ _flow_rate_length = 10
+ _blowout_length = 10
+ _lld_z_length = 10
+ _kwargs_length = 15
+ _tip_type_length = 12
+ _max_volume_length = 16
+ _fitting_depth_length = 20
+ _tip_length_length = 16
+ # _pickup_method_length = 20
+ _filter_length = 10
+
+ def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
+ """Initialize a chatter box backend."""
+ super().__init__()
+ self._num_channels = num_channels
+ self.tip_length = tip_length
+ self.total_height = total_height
+# rclpy.init()
+ if not rclpy.ok():
+ rclpy.init()
+ self.joint_state_publisher = None
+ self.hardware_interface = PipetteController(port=port)
+
+ async def setup(self):
+ # self.joint_state_publisher = JointStatePublisher()
+ # self.hardware_interface.xyz_controller.connect_device()
+ # self.hardware_interface.xyz_controller.home_all_axes()
+ await super().setup()
+ self.hardware_interface.connect()
+ self.hardware_interface.initialize()
+
+ print("Setting up the liquid handler.")
+
+ async def stop(self):
+ print("Stopping the liquid handler.")
+
+ def serialize(self) -> dict:
+ return {**super().serialize(), "num_channels": self.num_channels}
+
+ def pipette_aspirate(self, volume: float, flow_rate: float):
+
+ self.hardware_interface.pipette.set_max_speed(flow_rate)
+ res = self.hardware_interface.pipette.aspirate(volume=volume)
+
+ if not res:
+ self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
+ return
+
+ self.hardware_interface.current_volume += volume
+
+ def pipette_dispense(self, volume: float, flow_rate: float):
+
+ self.hardware_interface.pipette.set_max_speed(flow_rate)
+ res = self.hardware_interface.pipette.dispense(volume=volume)
+ if not res:
+ self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
+ return
+ self.hardware_interface.current_volume -= volume
+
+ @property
+ def num_channels(self) -> int:
+ return self._num_channels
+
+ async def assigned_resource_callback(self, resource: Resource):
+ print(f"Resource {resource.name} was assigned to the liquid handler.")
+
+ async def unassigned_resource_callback(self, name: str):
+ print(f"Resource {name} was unassigned from the liquid handler.")
+
+ async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
+ print("Picking up tips:")
+ # print(ops.tip)
+ header = (
+ f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
+ f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
+ f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
+ f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
+ f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
+ f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
+ f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
+ # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
+ f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
+ )
+ # print(header)
+
+ for op, channel in zip(ops, use_channels):
+ offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
+ row = (
+ f" p{channel}: "
+ f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
+ f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
+ f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
+ f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
+ f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
+ f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
+ # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
+ f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
+ )
+ # print(row)
+ # print(op.resource.get_absolute_location())
+
+ self.tip_length = ops[0].tip.total_tip_length
+ coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
+ offset_xyz = ops[0].offset
+ x = coordinate.x + offset_xyz.x
+ y = coordinate.y + offset_xyz.y
+ z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
+ # print("moving")
+ self.hardware_interface._update_tip_status()
+ if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
+ print("已有枪头,无需重复拾取")
+ return
+ self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=100)
+ self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
+ # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
+ # goback()
+
+
+
+
+ async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
+ print("Dropping tips:")
+ header = (
+ f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
+ f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
+ f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
+ f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
+ f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
+ f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
+ f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
+ # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
+ f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
+ )
+ # print(header)
+
+ for op, channel in zip(ops, use_channels):
+ offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
+ row = (
+ f" p{channel}: "
+ f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
+ f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
+ f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
+ f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
+ f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
+ f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
+ # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
+ f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
+ )
+ # print(row)
+
+ coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
+ offset_xyz = ops[0].offset
+ x = coordinate.x + offset_xyz.x
+ y = coordinate.y + offset_xyz.y
+ z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
+ # print(x, y, z)
+ # print("moving")
+ self.hardware_interface._update_tip_status()
+ if self.hardware_interface.tip_status == TipStatus.NO_TIP:
+ print("无枪头,无需丢弃")
+ return
+ self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
+ self.hardware_interface.eject_tip
+ self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
+
+ async def aspirate(
+ self,
+ ops: List[SingleChannelAspiration],
+ use_channels: List[int],
+ **backend_kwargs,
+ ):
+ print("Aspirating:")
+ header = (
+ f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
+ f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
+ f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
+ f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
+ f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
+ f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
+ f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
+ # f"{'liquids':<20}" # TODO: add liquids
+ )
+ for key in backend_kwargs:
+ header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
+ # print(header)
+
+ for o, p in zip(ops, use_channels):
+ offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
+ row = (
+ f" p{p}: "
+ f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
+ f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
+ f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
+ f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
+ f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
+ f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
+ # f"{o.liquids if o.liquids is not None else 'none'}"
+ )
+ for key, value in backend_kwargs.items():
+ if isinstance(value, list) and all(isinstance(v, bool) for v in value):
+ value = "".join("T" if v else "F" for v in value)
+ if isinstance(value, list):
+ value = "".join(map(str, value))
+ row += f" {value:<15}"
+ # print(row)
+ coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
+ offset_xyz = ops[0].offset
+ x = coordinate.x + offset_xyz.x
+ y = coordinate.y + offset_xyz.y
+ z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
+ # print(x, y, z)
+ # print("moving")
+
+ # 判断枪头是否存在
+ self.hardware_interface._update_tip_status()
+ if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
+ print("无枪头,无法吸液")
+ return
+ # 判断吸液量是否超过枪头容量
+ flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
+ blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
+ if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
+ self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
+ return
+
+ # 移动到吸液位置
+ self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
+ self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
+
+
+ self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
+ if blow_out_air_volume >0:
+ self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
+
+
+
+
+ async def dispense(
+ self,
+ ops: List[SingleChannelDispense],
+ use_channels: List[int],
+ **backend_kwargs,
+ ):
+ # print("Dispensing:")
+ header = (
+ f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
+ f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
+ f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
+ f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
+ f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
+ f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
+ f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
+ # f"{'liquids':<20}" # TODO: add liquids
+ )
+ for key in backend_kwargs:
+ header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
+ # print(header)
+
+ for o, p in zip(ops, use_channels):
+ offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
+ row = (
+ f" p{p}: "
+ f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
+ f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
+ f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
+ f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
+ f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
+ f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
+ # f"{o.liquids if o.liquids is not None else 'none'}"
+ )
+ for key, value in backend_kwargs.items():
+ if isinstance(value, list) and all(isinstance(v, bool) for v in value):
+ value = "".join("T" if v else "F" for v in value)
+ if isinstance(value, list):
+ value = "".join(map(str, value))
+ row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
+ # print(row)
+ coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
+ offset_xyz = ops[0].offset
+ x = coordinate.x + offset_xyz.x
+ y = coordinate.y + offset_xyz.y
+ z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
+ # print(x, y, z)
+ # print("moving")
+
+ # 判断枪头是否存在
+ self.hardware_interface._update_tip_status()
+ if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
+ print("无枪头,无法排液")
+ return
+ # 判断排液量是否超过枪头容量
+ flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
+ blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
+ if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
+ self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
+ return
+
+
+ # 移动到排液位置
+ self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
+ self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
+
+
+ self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
+ if blow_out_air_volume > 0:
+ self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
+ # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
+
+ async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
+ print(f"Picking up tips from {pickup.resource.name}.")
+
+ async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
+ print(f"Dropping tips to {drop.resource.name}.")
+
+ async def aspirate96(
+ self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
+ ):
+ if isinstance(aspiration, MultiHeadAspirationPlate):
+ resource = aspiration.wells[0].parent
+ else:
+ resource = aspiration.container
+ print(f"Aspirating {aspiration.volume} from {resource}.")
+
+ async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
+ if isinstance(dispense, MultiHeadDispensePlate):
+ resource = dispense.wells[0].parent
+ else:
+ resource = dispense.container
+ print(f"Dispensing {dispense.volume} to {resource}.")
+
+ async def pick_up_resource(self, pickup: ResourcePickup):
+ print(f"Picking up resource: {pickup}")
+
+ async def move_picked_up_resource(self, move: ResourceMove):
+ print(f"Moving picked up resource: {move}")
+
+ async def drop_resource(self, drop: ResourceDrop):
+ print(f"Dropping resource: {drop}")
+
+ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
+ return True
+
diff --git a/unilabos/devices/liquid_handling/laiyu/config/deckconfig.json b/unilabos/devices/liquid_handling/laiyu/config/deckconfig.json
new file mode 100644
index 00000000..ddda7e0f
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/controllers/__init__.py b/unilabos/devices/liquid_handling/laiyu/controllers/__init__.py
new file mode 100644
index 00000000..d50b1eca
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/controllers/coordinate_origin.json b/unilabos/devices/liquid_handling/laiyu/controllers/coordinate_origin.json
new file mode 100644
index 00000000..b21901ec
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/controllers/coordinate_origin.json
@@ -0,0 +1,14 @@
+{
+ "machine_origin_steps": {
+ "x": -198.43,
+ "y": -94.25,
+ "z": -0.73
+ },
+ "work_origin_steps": {
+ "x": 59.39,
+ "y": 216.99,
+ "z": 2.0
+ },
+ "is_homed": true,
+ "timestamp": "2025-10-29T20:34:11.749055"
+}
\ No newline at end of file
diff --git a/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py
new file mode 100644
index 00000000..e6ddd4f0
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py
@@ -0,0 +1,1097 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+移液控制器模块
+封装SOPA移液器的高级控制功能
+"""
+
+# 添加项目根目录到Python路径以解决模块导入问题
+import sys
+import os
+from tkinter import N
+
+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 logging
+from typing import Optional, List, Dict, Tuple
+from dataclasses import dataclass
+from enum import Enum
+
+from unilabos.devices.liquid_handling.laiyu.drivers.sopa_pipette_driver import (
+ SOPAPipette,
+ SOPAConfig,
+ SOPAStatusCode,
+ DetectionMode,
+ create_sopa_pipette,
+)
+# from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import (
+# XYZStepperController,
+# MotorAxis,
+# MotorStatus,
+# ModbusException
+# )
+
+from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import (
+ XYZController,
+ MotorAxis,
+ MotorStatus
+)
+
+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[XYZController] = None
+ self.xyz_port = xyz_port if xyz_port else port
+ self.xyz_connected = True
+
+ # 统计信息
+ # 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 = XYZController(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()
+ self.xyz_controller.home_all_axes()
+ self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0)
+ 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:
+ 是否成功
+ """
+ self._update_tip_status()
+ 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._update_tip_status()
+ # self.tip_status = TipStatus.TIP_ATTACHED
+ # self.tip_count += 1
+ self.current_volume = 0.0
+ if self.tip_status == TipStatus.TIP_ATTACHED:
+ logger.info("枪头装载成功")
+ return True
+ else :
+ logger.info("枪头装载失败")
+ return False
+ else:
+ logger.error("枪头装载失败 - Z轴移动失败")
+ return False
+
+ def eject_tip(self) -> bool:
+ """
+ 弹出枪头
+
+ Returns:
+ 是否成功
+ """
+
+ self._update_tip_status()
+
+ if self.tip_status == TipStatus.NO_TIP:
+ logger.warning("无枪头可弹出")
+ return True
+
+ try:
+ if self.pipette.eject_tip():
+ self._update_tip_status()
+ if 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:
+ 是否成功
+ """
+ self._update_tip_status()
+ 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:
+ 是否成功
+ """
+ self._update_tip_status()
+ 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:
+ """获取状态信息"""
+ self._update_tip_status()
+ 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/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
+ 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 not in ['n', 'no']:
+ xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
+
+ 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/liquid_handling/laiyu/controllers/xyz_controller.py b/unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py
new file mode 100644
index 00000000..e06624ad
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py
@@ -0,0 +1,1253 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+XYZ三轴步进电机控制器
+支持坐标系管理、限位开关回零、工作原点设定等功能
+
+主要功能:
+- 坐标系转换层(步数↔毫米)
+- 限位开关回零功能
+- 工作原点示教和保存
+- 安全限位检查
+- 运动控制接口
+
+"""
+
+import json
+import os
+from re import X
+import time
+from typing import Optional, Dict, Tuple, Union
+from dataclasses import dataclass, field, 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.liquid_handling.laiyu.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 = 200.0 # Z轴最大行程
+ reference_distance: Dict[str, float] = field(default_factory=lambda: {
+ "x": 29,
+ "y": -13,
+ "z": -75.5
+ })
+ # 安全移动参数
+ safe_z_height: float = 0.0 # Z轴安全移动高度 (mm) - 液体处理工作站安全高度
+ z_approach_height: float = 5.0 # Z轴接近高度 (mm) - 在目标位置上方的预备高度
+
+ # 回零参数
+ homing_speed: int = 50 # 回零速度 (mm/s)
+ homing_timeout: float = 30.0 # 回零超时时间
+ safe_clearance: float = 10.0 # 安全间隙 (mm)
+ position_stable_time: float = 1.0 # 位置稳定检测时间(秒)
+ position_check_interval: float = 0.2 # 位置检查间隔(秒)
+
+ # 运动参数
+ default_speed: int = 50 # 默认运动速度 (mm/s)
+ 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()
+ import os
+ self.origin_file = os.path.join(os.path.dirname(os.path.abspath(__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)
+
+ for key, value in origin_data["machine_origin_steps"].items():
+ origin_data["machine_origin_steps"][key] = round(self.mm_to_steps(MotorAxis[key.upper()], value), 2)
+
+ for key, value in origin_data["work_origin_steps"].items():
+ origin_data["work_origin_steps"][key] = round(self.mm_to_steps(MotorAxis[key.upper()], value), 2)
+
+ 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_data = asdict(self.coordinate_origin)
+ for key, value in json_data["machine_origin_steps"].items():
+ json_data["machine_origin_steps"][key] = round(self.steps_to_mm(MotorAxis[key.upper()], value), 2)
+
+ for key, value in json_data["work_origin_steps"].items():
+ json_data["work_origin_steps"][key] = round(self.steps_to_mm(MotorAxis[key.upper()], value), 2)
+
+ json.dump(json_data, 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 + self.coordinate_origin.machine_origin_steps['x']
+
+ 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 + self.coordinate_origin.machine_origin_steps['y']
+
+ 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 + self.coordinate_origin.machine_origin_steps['z']
+
+ 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 - self.coordinate_origin.machine_origin_steps[axis_name]
+ 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:
+ """检查行程限制"""
+ min_x = min(0, -50)
+ min_y = min(0, -50)
+ min_z = min(0, -50)
+
+ if x is not None and (x < min_x or x > self.machine_config.max_travel_x):
+ raise CoordinateSystemError(f"X轴超出行程范围: {x}mm ({min_x} ~ {self.machine_config.max_travel_x}mm)")
+
+ if y is not None and (y < min_y or y > self.machine_config.max_travel_y):
+ raise CoordinateSystemError(f"Y轴超出行程范围: {y}mm ({min_y} ~ {self.machine_config.max_travel_y}mm)")
+
+ if z is not None and (z < min_z or z > self.machine_config.max_travel_z):
+ raise CoordinateSystemError(f"Z轴超出行程范围: {z}mm ({min_z} ~ {self.machine_config.max_travel_z}mm)")
+
+ return True
+
+ # ==================== 回零和原点设定方法 ====================
+
+ def home_axis(self, axis: MotorAxis, direction: int = -1, speed: float = None) -> 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}轴使能失败")
+
+ # 设置回零速度模式,根据方向设置正负
+ if speed is None:
+ speed_ = self.machine_config.homing_speed
+ else:
+ speed_ = speed
+
+ speed_ = min(max(speed_, 0), 500)
+ if not self.set_speed_mode(axis, self.ms_to_rpm(axis, speed_) * direction):
+ 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.ms_to_rpm(axis, 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()
+
+ machine_steps = {
+ 'x': self.mm_to_steps(MotorAxis.X, self.machine_config.reference_distance['x']),
+ 'y': self.mm_to_steps(MotorAxis.Y, self.machine_config.reference_distance['y']),
+ 'z': self.mm_to_steps(MotorAxis.Z, self.machine_config.reference_distance['z'])
+ }
+
+ for axis in MotorAxis:
+ axis_name = axis.name.lower()
+ current_steps = positions[axis].steps
+ self.coordinate_origin.work_origin_steps[axis_name] = current_steps + machine_steps[axis_name] - self.coordinate_origin.machine_origin_steps[axis_name]
+
+ logger.info(f"{axis.name}轴工作原点设置为: {current_steps + machine_steps[axis_name] - self.coordinate_origin.machine_origin_steps[axis_name]}步 "
+ f"({self.steps_to_mm(axis, current_steps + machine_steps[axis_name] - self.coordinate_origin.machine_origin_steps[axis_name]):.2f}mm)")
+
+ self._save_coordinate_origin()
+ logger.info("工作原点设置完成")
+ return True
+
+ except Exception as e:
+ logger.error(f"设置工作原点失败: {e}")
+ return False
+
+ def ms_to_rpm(self, axis: MotorAxis, velocity_mms: float) -> int:
+ """
+ 将速度从米/秒(m/s)转换为转速(rpm)
+
+ Args:
+ axis: 电机轴
+ velocity_ms: 速度(米/秒)
+
+ Returns:
+ 转速(rpm)
+ """
+ # 获取每转的行程(mm)
+ if axis == MotorAxis.X:
+ lead_mm = 80.0
+ elif axis == MotorAxis.Y:
+ lead_mm = 80.0
+ elif axis == MotorAxis.Z:
+ lead_mm = 5.0
+ else:
+ raise ValueError(f"未知轴: {axis}")
+
+ # mm/s -> rps
+ rps = velocity_mms / lead_mm
+ # rps -> rpm
+ rpm = int(rps * 60.0)
+ return min(max(rpm, 0), 150)
+
+
+ # ==================== 高级运动控制方法 ====================
+
+ def move_to_work_coord_safe(self, x: float = None, y: float = None, z: float = None,
+ speed: float = None, acceleration: int = None) -> bool:
+ """
+ 安全移动到工作坐标系指定位置 (液体处理工作站专用)
+ 移动策略:Z轴先上升到安全高度 -> XY轴移动到目标位置 -> Z轴下降到目标位置
+
+ Args:
+ x, y, z: 工作坐标系下的目标位置 (mm)
+ speed: 运动速度 (m/s)
+ 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
+
+ xy_success = True
+ # 步骤1: Z轴先上升到安全高度
+
+ machine_steps = self.work_to_machine_steps(x, y, z)
+
+ if z is not None and (x is not None or y 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'], self.ms_to_rpm(MotorAxis.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轴移动到目标位置
+ if x is not None:
+ if not self.move_to_position(MotorAxis.X, machine_steps['x'], self.ms_to_rpm(MotorAxis.X, speed), acceleration):
+ xy_success = False
+ if y is not None:
+ if not self.move_to_position(MotorAxis.Y, machine_steps['y'], self.ms_to_rpm(MotorAxis.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:
+ if not self.move_to_position(MotorAxis.Z, machine_steps['z'], self.ms_to_rpm(MotorAxis.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: float = None, acceleration: int = None) -> bool:
+ """
+ 相对当前位置移动
+
+ Args:
+ dx, dy, dz: 相对移动距离 (mm)
+ speed: 运动速度 (m/s)
+ 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(x=target_x, y=target_y, z=target_z, speed=speed, acceleration=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 <速度> - 设置运动速度(mm/s),例: 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=50):
+ 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} m/s")
+ 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 200")
+ continue
+
+ try:
+ new_speed = int(parts[1])
+ if new_speed <= 0:
+ print("速度必须大于0")
+ continue
+ if new_speed > 500:
+ print("速度不能超过500 mm/s")
+ continue
+
+ current_speed = new_speed
+ print(f"运动速度设置为: {current_speed} mm/s")
+
+ 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} mm/s")
+ print(f" 默认速度: {config.default_speed} mm/s")
+ 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=50,
+ default_speed=50
+ )
+ 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/ttyUSB_CH340',
+ 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/ttyUSB_CH340'
+ ]
+
+ 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()
+ # xyz_controller = XYZController(port='/dev/ttyUSB_CH340', auto_connect=True)
+ # # xyz_controller.stop_all_axes()
+ # xyz_controller.connect_device()
+ # time.sleep(1)
+ # xyz_controller.home_all_axes()
diff --git a/unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py b/unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py
new file mode 100644
index 00000000..96092556
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.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/liquid_handling/laiyu/core/__init__.py b/unilabos/devices/liquid_handling/laiyu/core/__init__.py
new file mode 100644
index 00000000..e4d2baa9
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/core/__init__.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+LaiYu液体处理设备核心模块
+
+该模块包含LaiYu液体处理设备的核心功能组件:
+- LaiYu_Liquid.py: 主设备类和配置管理
+- abstract_protocol.py: 抽象协议定义
+- laiyu_liquid_res.py: 设备资源管理
+
+作者: UniLab团队
+版本: 2.0.0
+"""
+
+from .LaiYu_Liquid import (
+ LaiYuLiquid,
+ LaiYuLiquidConfig,
+ LaiYuLiquidDeck,
+ LaiYuLiquidContainer,
+ LaiYuLiquidTipRack,
+ create_quick_setup
+)
+
+from .laiyu_liquid_res import (
+ LaiYuLiquidDeck,
+ LaiYuLiquidContainer,
+ LaiYuLiquidTipRack
+)
+
+__all__ = [
+ # 主设备类
+ 'LaiYuLiquid',
+ 'LaiYuLiquidConfig',
+
+ # 设备资源
+ 'LaiYuLiquidDeck',
+ 'LaiYuLiquidContainer',
+ 'LaiYuLiquidTipRack',
+
+ # 工具函数
+ 'create_quick_setup'
+]
\ No newline at end of file
diff --git a/unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py b/unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py
new file mode 100644
index 00000000..9959c364
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/core/laiyu_liquid_res.py b/unilabos/devices/liquid_handling/laiyu/core/laiyu_liquid_res.py
new file mode 100644
index 00000000..0c127539
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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 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/liquid_handling/laiyu/docs/CHANGELOG.md b/unilabos/devices/liquid_handling/laiyu/docs/CHANGELOG.md
new file mode 100644
index 00000000..a0f2b632
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/docs/hardware/SOPA气动式移液器RS485控制指令.md b/unilabos/devices/liquid_handling/laiyu/docs/hardware/SOPA气动式移液器RS485控制指令.md
new file mode 100644
index 00000000..6db19eb1
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md b/unilabos/devices/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md
new file mode 100644
index 00000000..e7013484
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md b/unilabos/devices/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md
new file mode 100644
index 00000000..64529097
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/docs/readme.md b/unilabos/devices/liquid_handling/laiyu/docs/readme.md
new file mode 100644
index 00000000..b81ba93a
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/docs/readme.md
@@ -0,0 +1,285 @@
+# LaiYu_Liquid 液体处理工作站 - 生产就绪版本
+
+## 概述
+
+LaiYu_Liquid 是一个完全集成到 UniLabOS 系统的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
+
+## 系统组成
+
+### 硬件组件
+- **XYZ三轴运动平台**: 3个RS485步进电机驱动(地址:X轴=0x01, Y轴=0x02, Z轴=0x03)
+- **SOPA气动式移液器**: RS485总线控制,支持精密液体处理操作
+- **通信接口**: RS485转USB模块,默认波特率115200
+- **机械结构**: 稳固工作台面,支持离心管架、96孔板等标准实验耗材
+
+### 软件架构
+- **驱动层**: 底层硬件通信驱动,支持RS485协议
+- **控制层**: 高级控制逻辑和坐标系管理
+- **抽象层**: 完全符合UniLabOS标准的液体处理接口
+- **资源层**: 标准化的实验器具和耗材管理
+
+## 🎯 生产就绪组件
+
+### ✅ 核心驱动程序 (`drivers/`)
+- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
+ - 支持液体吸取、分配、检测
+ - 完整的错误处理和状态管理
+ - 生产级别的通信协议实现
+
+- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
+ - 精确的位置控制和运动规划
+ - 安全限位和错误检测
+ - 高性能运动控制算法
+
+### ✅ 高级控制器 (`controllers/`)
+- **`pipette_controller.py`** - 移液控制器
+ - 封装高级液体处理功能
+ - 支持多种液体类型和处理参数
+ - 智能错误恢复机制
+
+- **`xyz_controller.py`** - XYZ运动控制器
+ - 坐标系管理和转换
+ - 运动路径优化
+ - 安全运动控制
+
+### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
+- **完整的液体处理抽象接口**
+- **标准化的资源管理系统**
+- **与PyLabRobot兼容的后端实现**
+- **生产级别的错误处理和日志记录**
+
+### ✅ 资源管理系统
+- **`laiyu_liquid_res.py`** - 标准化资源定义
+ - 96孔板、离心管架、枪头架等标准器具
+ - 自动化的资源创建和配置函数
+ - 与工作台布局的完美集成
+
+### ✅ 配置管理 (`config/`)
+- **`config/deck.json`** - 工作台布局配置
+ - 精确的空间定义和槽位管理
+ - 支持多种实验器具的标准化放置
+ - 可扩展的配置架构
+
+- **`__init__.py`** - 模块集成和导出
+ - 完整的API导出和版本管理
+ - 依赖检查和安装验证
+ - 专业的模块信息展示
+
+### ✅ 可视化支持
+- **`rviz_backend.py`** - RViz可视化后端
+ - 实时运动状态可视化
+ - 液体处理过程监控
+ - 与ROS系统的无缝集成
+
+## 🚀 核心功能特性
+
+### 液体处理能力
+- **精密体积控制**: 支持1-1000μL精确分配
+- **多种液体类型**: 水性、有机溶剂、粘稠液体等
+- **智能检测**: 液位检测、气泡检测、堵塞检测
+- **自动化流程**: 完整的吸取-转移-分配工作流
+
+### 运动控制系统
+- **三轴精密定位**: 微米级精度控制
+- **路径优化**: 智能运动规划和碰撞避免
+- **安全机制**: 限位保护、紧急停止、错误恢复
+- **坐标系管理**: 工作坐标与机械坐标的自动转换
+
+### 资源管理
+- **标准化器具**: 支持96孔板、离心管架、枪头架等
+- **状态跟踪**: 实时监控液体体积、枪头状态等
+- **自动配置**: 基于JSON的灵活配置系统
+- **扩展性**: 易于添加新的器具类型
+
+## 📁 目录结构
+
+```
+LaiYu_Liquid/
+├── __init__.py # 模块初始化和API导出
+├── readme.md # 本文档
+├── rviz_backend.py # RViz可视化后端
+├── backend/ # 后端驱动模块
+│ ├── __init__.py
+│ └── laiyu_backend.py # PyLabRobot兼容后端
+├── core/ # 核心模块
+│ ├── core/
+│ │ └── LaiYu_Liquid.py # 主设备类
+│ ├── abstract_protocol.py # 抽象协议
+│ └── laiyu_liquid_res.py # 设备资源定义
+├── config/ # 配置文件目录
+│ └── deck.json # 工作台布局配置
+├── controllers/ # 高级控制器
+│ ├── __init__.py
+│ ├── pipette_controller.py # 移液控制器
+│ └── xyz_controller.py # XYZ运动控制器
+├── docs/ # 技术文档
+│ ├── SOPA气动式移液器RS485控制指令.md
+│ ├── 步进电机控制指令.md
+│ └── hardware/ # 硬件相关文档
+├── drivers/ # 底层驱动程序
+│ ├── __init__.py
+│ ├── sopa_pipette_driver.py # SOPA移液器驱动
+│ └── xyz_stepper_driver.py # XYZ步进电机驱动
+└── tests/ # 测试文件
+```
+
+## 🔧 快速开始
+
+### 1. 安装和验证
+
+```python
+# 验证模块安装
+from unilabos.devices.laiyu_liquid import (
+ LaiYuLiquid,
+ LaiYuLiquidConfig,
+ create_quick_setup,
+ print_module_info
+)
+
+# 查看模块信息
+print_module_info()
+
+# 快速创建默认资源
+resources = create_quick_setup()
+print(f"已创建 {len(resources)} 个资源")
+```
+
+### 2. 基本使用示例
+
+```python
+from unilabos.devices.LaiYu_Liquid import (
+ create_quick_setup,
+ create_96_well_plate,
+ create_laiyu_backend
+)
+
+# 快速创建默认资源
+resources = create_quick_setup()
+print(f"创建了以下资源: {list(resources.keys())}")
+
+# 创建96孔板
+plate_96 = create_96_well_plate("test_plate")
+print(f"96孔板包含 {len(plate_96.children)} 个孔位")
+
+# 创建后端实例(用于PyLabRobot集成)
+backend = create_laiyu_backend("LaiYu_Device")
+print(f"后端设备: {backend.name}")
+```
+
+### 3. 后端驱动使用
+
+```python
+from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
+
+# 创建后端实例
+backend = create_laiyu_backend("LaiYu_Liquid_Station")
+
+# 连接设备
+await backend.connect()
+
+# 设备归位
+await backend.home_device()
+
+# 获取设备状态
+status = await backend.get_status()
+print(f"设备状态: {status}")
+
+# 断开连接
+await backend.disconnect()
+```
+
+### 4. 资源管理示例
+
+```python
+from unilabos.devices.LaiYu_Liquid import (
+ create_centrifuge_tube_rack,
+ create_tip_rack,
+ load_deck_config
+)
+
+# 加载工作台配置
+deck_config = load_deck_config()
+print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
+
+# 创建不同类型的资源
+tube_rack = create_centrifuge_tube_rack("sample_rack")
+tip_rack = create_tip_rack("tip_rack_200ul")
+
+print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
+print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
+```
+
+## 🔍 技术架构
+
+### 坐标系统
+- **机械坐标**: 基于步进电机的原始坐标系统
+- **工作坐标**: 用户友好的实验室坐标系统
+- **自动转换**: 透明的坐标系转换和校准
+
+### 通信协议
+- **RS485总线**: 高可靠性工业通信标准
+- **Modbus协议**: 标准化的设备通信协议
+- **错误检测**: 完整的通信错误检测和恢复
+
+### 安全机制
+- **限位保护**: 硬件和软件双重限位保护
+- **紧急停止**: 即时停止所有运动和操作
+- **状态监控**: 实时设备状态监控和报警
+
+## 🧪 验证和测试
+
+### 功能验证
+```python
+# 验证模块安装
+from unilabos.devices.laiyu_liquid import validate_installation
+validate_installation()
+
+# 查看模块信息
+from unilabos.devices.laiyu_liquid import print_module_info
+print_module_info()
+```
+
+### 硬件连接测试
+```python
+# 测试SOPA移液器连接
+from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
+
+config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
+pipette = SOPAPipette(config)
+success = pipette.connect()
+print(f"SOPA连接状态: {'成功' if success else '失败'}")
+```
+
+## 📚 维护和支持
+
+### 日志记录
+- **结构化日志**: 使用Python logging模块的专业日志记录
+- **错误追踪**: 详细的错误信息和堆栈跟踪
+- **性能监控**: 操作时间和性能指标记录
+
+### 配置管理
+- **JSON配置**: 灵活的JSON格式配置文件
+- **参数验证**: 自动配置参数验证和错误提示
+- **热重载**: 支持配置文件的动态重载
+
+### 扩展性
+- **模块化设计**: 易于扩展和定制的模块化架构
+- **插件接口**: 支持第三方插件和扩展
+- **API兼容**: 向后兼容的API设计
+
+## 📞 技术支持
+
+### 常见问题
+1. **串口权限问题**: 确保用户有串口访问权限
+2. **依赖库安装**: 使用pip安装所需的Python库
+3. **设备连接**: 检查RS485适配器和设备地址配置
+
+### 联系方式
+- **技术文档**: 查看UniLabOS官方文档
+- **问题反馈**: 通过GitHub Issues提交问题
+- **社区支持**: 加入UniLabOS开发者社区
+
+---
+
+**LaiYu_Liquid v1.0.0** - 生产就绪的液体处理工作站集成模块
+© 2024 UniLabOS Project. All rights reserved.
\ No newline at end of file
diff --git a/unilabos/devices/liquid_handling/laiyu/drivers/__init__.py b/unilabos/devices/liquid_handling/laiyu/drivers/__init__.py
new file mode 100644
index 00000000..cedd47a0
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/drivers/sopa_pipette_driver.py b/unilabos/devices/liquid_handling/laiyu/drivers/sopa_pipette_driver.py
new file mode 100644
index 00000000..0e71bc71
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/drivers/sopa_pipette_driver.py
@@ -0,0 +1,1085 @@
+#!/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:
+ # 解析响应中的枪头状态
+ if "T1" in response:
+ self._tip_present = True
+ return True
+ elif "T0" in response:
+ self._tip_present = False
+ return False
+ else:
+ logger.error(f"获取枪头状态失败: {response}")
+ return False
+ 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/liquid_handling/laiyu/drivers/xyz_stepper_driver.py b/unilabos/devices/liquid_handling/laiyu/drivers/xyz_stepper_driver.py
new file mode 100644
index 00000000..8505ba76
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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(f"CRC校验失败{response}")
+
+ return response
+
+ def read_registers(self, slave_addr: int, start_addr: int, count: int) -> list:
+ """
+ 读取保持寄存器
+
+ Args:
+ slave_addr: 从站地址
+ start_addr: 起始地址
+ count: 寄存器数量
+
+ Returns:
+ 寄存器值列表
+ """
+ data = struct.pack('>BHH', ModbusFunction.READ_HOLDING_REGISTERS.value, start_addr, count)
+ response = self._send_command(slave_addr, data)
+
+ if len(response) < 5:
+ raise ModbusException("响应长度不足")
+
+ if response[1] != ModbusFunction.READ_HOLDING_REGISTERS.value:
+ raise ModbusException(f"功能码错误: {response[1]:02X}")
+
+ byte_count = response[2]
+ values = []
+ for i in range(0, byte_count, 2):
+ value = struct.unpack('>H', response[3+i:5+i])[0]
+ values.append(value)
+
+ return values
+
+ def write_single_register(self, slave_addr: int, addr: int, value: int) -> bool:
+ """
+ 写入单个寄存器
+
+ Args:
+ slave_addr: 从站地址
+ addr: 寄存器地址
+ value: 寄存器值
+
+ Returns:
+ 写入是否成功
+ """
+ data = struct.pack('>BHH', ModbusFunction.WRITE_SINGLE_REGISTER.value, addr, value)
+ response = self._send_command(slave_addr, data)
+
+ return len(response) >= 8 and response[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
+
+ def write_multiple_registers(self, slave_addr: int, start_addr: int, values: list) -> bool:
+ """
+ 写入多个寄存器
+
+ Args:
+ slave_addr: 从站地址
+ start_addr: 起始地址
+ values: 寄存器值列表
+
+ Returns:
+ 写入是否成功
+ """
+ byte_count = len(values) * 2
+ data = struct.pack('>BHHB', ModbusFunction.WRITE_MULTIPLE_REGISTERS.value,
+ start_addr, len(values), byte_count)
+
+ for value in values:
+ data += struct.pack('>H', value)
+
+ response = self._send_command(slave_addr, data)
+
+ return len(response) >= 8 and response[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
+
+
+class XYZStepperController(StepperMotorDriver):
+ """XYZ三轴步进电机控制器"""
+
+ # 电机配置常量
+ STEPS_PER_REVOLUTION = 16384 # 每圈步数
+
+ def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
+ """
+ 初始化XYZ三轴步进电机控制器
+
+ Args:
+ port: 串口端口名
+ baudrate: 波特率
+ timeout: 通信超时时间
+ """
+ super().__init__(port, baudrate, timeout)
+ self.axis_addresses = {
+ MotorAxis.X: 1,
+ MotorAxis.Y: 2,
+ MotorAxis.Z: 3
+ }
+
+ def degrees_to_steps(self, degrees: float) -> int:
+ """
+ 将角度转换为步数
+
+ Args:
+ degrees: 角度值
+
+ Returns:
+ 对应的步数
+ """
+ return int(degrees * self.STEPS_PER_REVOLUTION / 360.0)
+
+ def steps_to_degrees(self, steps: int) -> float:
+ """
+ 将步数转换为角度
+
+ Args:
+ steps: 步数
+
+ Returns:
+ 对应的角度值
+ """
+ return steps * 360.0 / self.STEPS_PER_REVOLUTION
+
+ def revolutions_to_steps(self, revolutions: float) -> int:
+ """
+ 将圈数转换为步数
+
+ Args:
+ revolutions: 圈数
+
+ Returns:
+ 对应的步数
+ """
+ return int(revolutions * self.STEPS_PER_REVOLUTION)
+
+ def steps_to_revolutions(self, steps: int) -> float:
+ """
+ 将步数转换为圈数
+
+ Args:
+ steps: 步数
+
+ Returns:
+ 对应的圈数
+ """
+ return steps / self.STEPS_PER_REVOLUTION
+
+ def get_motor_status(self, axis: MotorAxis) -> MotorPosition:
+ """
+ 获取电机状态信息
+
+ Args:
+ axis: 电机轴
+
+ Returns:
+ 电机位置信息
+ """
+ addr = self.axis_addresses[axis]
+
+ # 读取状态、位置、速度、电流
+ values = self.read_registers(addr, self.REG_STATUS, 6)
+
+ status = MotorStatus(values[0])
+ position_high = values[1]
+ position_low = values[2]
+ speed = values[3]
+ current = values[5]
+
+ # 合并32位位置
+ position = (position_high << 16) | position_low
+ # 处理有符号数
+ if position > 0x7FFFFFFF:
+ position -= 0x100000000
+
+ return MotorPosition(position, speed, current, status)
+
+ def emergency_stop(self, axis: MotorAxis) -> bool:
+ """
+ 紧急停止电机
+
+ Args:
+ axis: 电机轴
+
+ Returns:
+ 操作是否成功
+ """
+ addr = self.axis_addresses[axis]
+ return self.write_single_register(addr, self.REG_EMERGENCY_STOP, 0x0000)
+
+ def enable_motor(self, axis: MotorAxis, enable: bool = True) -> bool:
+ """
+ 使能/失能电机
+
+ Args:
+ axis: 电机轴
+ enable: True为使能,False为失能
+
+ Returns:
+ 操作是否成功
+ """
+ addr = self.axis_addresses[axis]
+ value = 0x0001 if enable else 0x0000
+ return self.write_single_register(addr, self.REG_ENABLE, value)
+
+ def move_to_position(self, axis: MotorAxis, position: int, speed: int = 5000,
+ acceleration: int = 1000, precision: int = 100) -> bool:
+ """
+ 移动到指定位置
+
+ Args:
+ axis: 电机轴
+ position: 目标位置(步数)
+ speed: 运行速度(rpm)
+ acceleration: 加速度(rpm/s)
+ precision: 到位精度
+
+ Returns:
+ 操作是否成功
+ """
+ addr = self.axis_addresses[axis]
+
+ # 处理32位位置
+ if position < 0:
+ position += 0x100000000
+
+ position_high = (position >> 16) & 0xFFFF
+ position_low = position & 0xFFFF
+
+ values = [
+ position_high, # 目标位置高位
+ position_low, # 目标位置低位
+ 0x0000, # 保留
+ speed, # 速度
+ acceleration, # 加速度
+ precision # 精度
+ ]
+
+ return self.write_multiple_registers(addr, self.REG_TARGET_POSITION_HIGH, values)
+
+ def set_speed_mode(self, axis: MotorAxis, speed: int, acceleration: int = 1000) -> bool:
+ """
+ 设置速度模式运行
+
+ Args:
+ axis: 电机轴
+ speed: 运行速度(rpm),正值正转,负值反转
+ acceleration: 加速度(rpm/s)
+
+ Returns:
+ 操作是否成功
+ """
+ addr = self.axis_addresses[axis]
+
+ # 处理负数
+ if speed < 0:
+ speed = 0x10000 + speed # 补码表示
+
+ values = [0x0000, speed, acceleration, 0x0000]
+
+ return self.write_multiple_registers(addr, 0x60, values)
+
+ def home_axis(self, axis: MotorAxis) -> bool:
+ """
+ 轴归零操作
+
+ Args:
+ axis: 电机轴
+
+ Returns:
+ 操作是否成功
+ """
+ addr = self.axis_addresses[axis]
+ return self.write_single_register(addr, self.REG_ZERO_SINGLE, 0x0001)
+
+ def wait_for_completion(self, axis: MotorAxis, timeout: float = 30.0) -> bool:
+ """
+ 等待电机运动完成
+
+ Args:
+ axis: 电机轴
+ timeout: 超时时间(秒)
+
+ Returns:
+ 是否在超时前完成
+ """
+ start_time = time.time()
+
+ while time.time() - start_time < timeout:
+ status = self.get_motor_status(axis)
+ if status.status == MotorStatus.STANDBY:
+ return True
+ time.sleep(0.1)
+
+ logger.warning(f"{axis.name}轴运动超时")
+ return False
+
+ def move_xyz(self, x: Optional[int] = None, y: Optional[int] = None, z: Optional[int] = None,
+ speed: int = 5000, acceleration: int = 1000) -> Dict[MotorAxis, bool]:
+ """
+ 同时控制XYZ轴移动
+
+ Args:
+ x: X轴目标位置
+ y: Y轴目标位置
+ z: Z轴目标位置
+ speed: 运行速度
+ acceleration: 加速度
+
+ Returns:
+ 各轴操作结果字典
+ """
+ results = {}
+
+ if x is not None:
+ results[MotorAxis.X] = self.move_to_position(MotorAxis.X, x, speed, acceleration)
+
+ if y is not None:
+ results[MotorAxis.Y] = self.move_to_position(MotorAxis.Y, y, speed, acceleration)
+
+ if z is not None:
+ results[MotorAxis.Z] = self.move_to_position(MotorAxis.Z, z, speed, acceleration)
+
+ return results
+
+ def move_xyz_degrees(self, x_deg: Optional[float] = None, y_deg: Optional[float] = None,
+ z_deg: Optional[float] = None, speed: int = 5000,
+ acceleration: int = 1000) -> Dict[MotorAxis, bool]:
+ """
+ 使用角度值同时移动多个轴到指定位置
+
+ Args:
+ x_deg: X轴目标角度(度)
+ y_deg: Y轴目标角度(度)
+ z_deg: Z轴目标角度(度)
+ speed: 移动速度
+ acceleration: 加速度
+
+ Returns:
+ 各轴移动操作结果
+ """
+ # 将角度转换为步数
+ x_steps = self.degrees_to_steps(x_deg) if x_deg is not None else None
+ y_steps = self.degrees_to_steps(y_deg) if y_deg is not None else None
+ z_steps = self.degrees_to_steps(z_deg) if z_deg is not None else None
+
+ return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
+
+ def move_xyz_revolutions(self, x_rev: Optional[float] = None, y_rev: Optional[float] = None,
+ z_rev: Optional[float] = None, speed: int = 5000,
+ acceleration: int = 1000) -> Dict[MotorAxis, bool]:
+ """
+ 使用圈数值同时移动多个轴到指定位置
+
+ Args:
+ x_rev: X轴目标圈数
+ y_rev: Y轴目标圈数
+ z_rev: Z轴目标圈数
+ speed: 移动速度
+ acceleration: 加速度
+
+ Returns:
+ 各轴移动操作结果
+ """
+ # 将圈数转换为步数
+ x_steps = self.revolutions_to_steps(x_rev) if x_rev is not None else None
+ y_steps = self.revolutions_to_steps(y_rev) if y_rev is not None else None
+ z_steps = self.revolutions_to_steps(z_rev) if z_rev is not None else None
+
+ return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
+
+ def move_to_position_degrees(self, axis: MotorAxis, degrees: float, speed: int = 5000,
+ acceleration: int = 1000, precision: int = 100) -> bool:
+ """
+ 使用角度值移动单个轴到指定位置
+
+ Args:
+ axis: 电机轴
+ degrees: 目标角度(度)
+ speed: 移动速度
+ acceleration: 加速度
+ precision: 精度
+
+ Returns:
+ 移动操作是否成功
+ """
+ steps = self.degrees_to_steps(degrees)
+ return self.move_to_position(axis, steps, speed, acceleration, precision)
+
+ def move_to_position_revolutions(self, axis: MotorAxis, revolutions: float, speed: int = 5000,
+ acceleration: int = 1000, precision: int = 100) -> bool:
+ """
+ 使用圈数值移动单个轴到指定位置
+
+ Args:
+ axis: 电机轴
+ revolutions: 目标圈数
+ speed: 移动速度
+ acceleration: 加速度
+ precision: 精度
+
+ Returns:
+ 移动操作是否成功
+ """
+ steps = self.revolutions_to_steps(revolutions)
+ return self.move_to_position(axis, steps, speed, acceleration, precision)
+
+ def stop_all_axes(self) -> Dict[MotorAxis, bool]:
+ """
+ 紧急停止所有轴
+
+ Returns:
+ 各轴停止结果字典
+ """
+ results = {}
+ for axis in MotorAxis:
+ results[axis] = self.emergency_stop(axis)
+ return results
+
+ def enable_all_axes(self, enable: bool = True) -> Dict[MotorAxis, bool]:
+ """
+ 使能/失能所有轴
+
+ Args:
+ enable: True为使能,False为失能
+
+ Returns:
+ 各轴操作结果字典
+ """
+ results = {}
+ for axis in MotorAxis:
+ results[axis] = self.enable_motor(axis, enable)
+ return results
+
+ def get_all_positions(self) -> Dict[MotorAxis, MotorPosition]:
+ """
+ 获取所有轴的位置信息
+
+ Returns:
+ 各轴位置信息字典
+ """
+ positions = {}
+ for axis in MotorAxis:
+ positions[axis] = self.get_motor_status(axis)
+ return positions
+
+ def home_all_axes(self) -> Dict[MotorAxis, bool]:
+ """
+ 所有轴归零
+
+ Returns:
+ 各轴归零结果字典
+ """
+ results = {}
+ for axis in MotorAxis:
+ results[axis] = self.home_axis(axis)
+ return results
diff --git a/unilabos/devices/liquid_handling/laiyu/laiyu.py b/unilabos/devices/liquid_handling/laiyu/laiyu.py
new file mode 100644
index 00000000..0d7074a7
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/laiyu.py
@@ -0,0 +1,218 @@
+import asyncio
+import collections
+import contextlib
+import json
+import time
+from typing import Any, List, Dict, Optional, TypedDict, Union, Sequence, Iterator, Literal
+
+from pylabrobot.liquid_handling import (
+ LiquidHandlerBackend,
+ Pickup,
+ SingleChannelAspiration,
+ Drop,
+ SingleChannelDispense,
+ PickupTipRack,
+ DropTipRack,
+ MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
+)
+from pylabrobot.liquid_handling.standard import (
+ MultiHeadAspirationContainer,
+ MultiHeadDispenseContainer,
+ MultiHeadDispensePlate,
+ ResourcePickup,
+ ResourceMove,
+ ResourceDrop,
+)
+from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash
+
+from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
+from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
+from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
+
+
+
+class TransformXYZDeck(Deck):
+ """Laiyu 的专用 Deck 类,继承自 Deck。
+
+ 该类定义了 Laiyu 的工作台布局和槽位信息。
+ """
+
+ def __init__(self, name: str, size_x: float, size_y: float, size_z: float):
+ super().__init__(name, size_x, size_y, size_z)
+ self.name = name
+
+class TransformXYZBackend(LiquidHandlerBackend):
+ def __init__(self, name: str, host: str, port: int, timeout: float):
+ super().__init__()
+ self.host = host
+ self.port = port
+ self.timeout = timeout
+
+class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
+ def __init__(self, name: str, channel_num: int):
+ super().__init__(channel_num)
+ self.name = name
+
+
+class TransformXYZContainer(Plate, TipRack):
+ """Laiyu 的专用 Container 类,继承自 Plate和TipRack。
+
+ 该类定义了 Laiyu 的工作台布局和槽位信息。
+ """
+
+ def __init__(
+ self,
+ name: str,
+ size_x: float,
+ size_y: float,
+ size_z: float,
+ category: str,
+ ordering: collections.OrderedDict,
+ model: Optional[str] = None,
+ ):
+ super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
+ self._unilabos_state = {}
+
+ def load_state(self, state: Dict[str, Any]) -> None:
+ """从给定的状态加载工作台信息。"""
+ super().load_state(state)
+ self._unilabos_state = state
+
+ def serialize_state(self) -> Dict[str, Dict[str, Any]]:
+ data = super().serialize_state()
+ data.update(self._unilabos_state)
+ return data
+
+class TransformXYZHandler(LiquidHandlerAbstract):
+ support_touch_tip = False
+
+ def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs):
+ # Handle case where deck is passed as a dict (from serialization)
+ if isinstance(deck, dict):
+ # Try to create a TransformXYZDeck from the dict
+ if 'name' in deck and 'size_x' in deck and 'size_y' in deck and 'size_z' in deck:
+ deck = TransformXYZDeck(
+ name=deck['name'],
+ size_x=deck.get('size_x', 100),
+ size_y=deck.get('size_y', 100),
+ size_z=deck.get('size_z', 100)
+ )
+ else:
+ # Fallback: create a basic deck
+ deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
+
+ if simulator:
+ self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num)
+ else:
+ self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout)
+ super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
+
+ async def add_liquid(
+ self,
+ asp_vols: Union[List[float], float],
+ dis_vols: Union[List[float], float],
+ reagent_sources: Sequence[Container],
+ targets: Sequence[Container],
+ *,
+ use_channels: Optional[List[int]] = None,
+ flow_rates: Optional[List[Optional[float]]] = None,
+ offsets: Optional[List[Coordinate]] = None,
+ liquid_height: Optional[List[Optional[float]]] = None,
+ blow_out_air_volume: Optional[List[Optional[float]]] = None,
+ spread: Optional[Literal["wide", "tight", "custom"]] = "wide",
+ is_96_well: bool = False,
+ delays: Optional[List[int]] = None,
+ mix_time: Optional[int] = None,
+ mix_vol: Optional[int] = None,
+ mix_rate: Optional[int] = None,
+ mix_liquid_height: Optional[float] = None,
+ none_keys: List[str] = [],
+ ):
+ pass
+
+ async def aspirate(
+ self,
+ resources: Sequence[Container],
+ vols: List[float],
+ use_channels: Optional[List[int]] = None,
+ flow_rates: Optional[List[Optional[float]]] = None,
+ offsets: Optional[List[Coordinate]] = None,
+ liquid_height: Optional[List[Optional[float]]] = None,
+ blow_out_air_volume: Optional[List[Optional[float]]] = None,
+ spread: Literal["wide", "tight", "custom"] = "wide",
+ **backend_kwargs,
+ ):
+ pass
+
+ async def dispense(
+ self,
+ resources: Sequence[Container],
+ vols: List[float],
+ use_channels: Optional[List[int]] = None,
+ flow_rates: Optional[List[Optional[float]]] = None,
+ offsets: Optional[List[Coordinate]] = None,
+ liquid_height: Optional[List[Optional[float]]] = None,
+ blow_out_air_volume: Optional[List[Optional[float]]] = None,
+ spread: Literal["wide", "tight", "custom"] = "wide",
+ **backend_kwargs,
+ ):
+ pass
+
+ async def drop_tips(
+ self,
+ tip_spots: Sequence[Union[TipSpot, Trash]],
+ use_channels: Optional[List[int]] = None,
+ offsets: Optional[List[Coordinate]] = None,
+ allow_nonzero_volume: bool = False,
+ **backend_kwargs,
+ ):
+ pass
+
+ async def mix(
+ self,
+ targets: Sequence[Container],
+ mix_time: int = None,
+ mix_vol: Optional[int] = None,
+ height_to_bottom: Optional[float] = None,
+ offsets: Optional[Coordinate] = None,
+ mix_rate: Optional[float] = None,
+ none_keys: List[str] = [],
+ ):
+ pass
+
+ async def pick_up_tips(
+ self,
+ tip_spots: List[TipSpot],
+ use_channels: Optional[List[int]] = None,
+ offsets: Optional[List[Coordinate]] = None,
+ **backend_kwargs,
+ ):
+ pass
+
+ async def transfer_liquid(
+ self,
+ sources: Sequence[Container],
+ targets: Sequence[Container],
+ tip_racks: Sequence[TipRack],
+ *,
+ use_channels: Optional[List[int]] = None,
+ asp_vols: Union[List[float], float],
+ dis_vols: Union[List[float], float],
+ asp_flow_rates: Optional[List[Optional[float]]] = None,
+ dis_flow_rates: Optional[List[Optional[float]]] = None,
+ offsets: Optional[List[Coordinate]] = None,
+ touch_tip: bool = False,
+ liquid_height: Optional[List[Optional[float]]] = None,
+ blow_out_air_volume: Optional[List[Optional[float]]] = None,
+ spread: Literal["wide", "tight", "custom"] = "wide",
+ is_96_well: bool = False,
+ mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
+ mix_times: Optional[List[int]] = None,
+ mix_vol: Optional[int] = None,
+ mix_rate: Optional[int] = None,
+ mix_liquid_height: Optional[float] = None,
+ delays: Optional[List[int]] = None,
+ none_keys: List[str] = [],
+ ):
+ pass
+
\ No newline at end of file
diff --git a/unilabos/devices/liquid_handling/laiyu/tests/__init__.py b/unilabos/devices/liquid_handling/laiyu/tests/__init__.py
new file mode 100644
index 00000000..7ff58fe2
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handling/laiyu/tests/test_deck_config.py b/unilabos/devices/liquid_handling/laiyu/tests/test_deck_config.py
new file mode 100644
index 00000000..04688302
--- /dev/null
+++ b/unilabos/devices/liquid_handling/laiyu/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/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py
index 6aefaf31..4142463d 100644
--- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py
+++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py
@@ -7,6 +7,8 @@ from collections import Counter
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast
from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness
+from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
+from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod
from pylabrobot.liquid_handling.standard import GripDirection
from pylabrobot.resources import (
@@ -29,12 +31,15 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class LiquidHandlerMiddleware(LiquidHandler):
- def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8):
+ def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, total_height: float = 310, **kwargs):
self._simulator = simulator
self.channel_num = channel_num
+ joint_config = kwargs.get("joint_config", None)
if simulator:
- self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num)
+ self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num,total_height, joint_config=joint_config, lh_device_id = deck.name)
self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False)
+ if hasattr(backend, "total_height"):
+ backend.total_height = total_height
super().__init__(backend, deck)
async def setup(self, **backend_kwargs):
@@ -217,7 +222,6 @@ class LiquidHandlerMiddleware(LiquidHandler):
offsets,
liquid_height,
blow_out_air_volume,
- spread,
**backend_kwargs,
)
@@ -540,16 +544,51 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
support_touch_tip = True
_ros_node: BaseROS2DeviceNode
- def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8):
+ def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8,total_height: float = 310,**backend_kwargs):
"""Initialize a LiquidHandler.
Args:
backend: Backend to use.
deck: Deck to use.
"""
+ backend_type = None
+ if isinstance(backend, dict) and "type" in backend:
+ backend_dict = backend.copy()
+ type_str = backend_dict.pop("type")
+ try:
+ # Try to get class from string using globals (current module), or fallback to pylabrobot or unilabos namespaces
+ backend_cls = None
+ if type_str in globals():
+ backend_cls = globals()[type_str]
+ else:
+ # Try resolving dotted notation, e.g. "xxx.yyy.ClassName"
+ components = type_str.split(".")
+ mod = None
+ if len(components) > 1:
+ module_name = ".".join(components[:-1])
+ try:
+ import importlib
+ mod = importlib.import_module(module_name)
+ except ImportError:
+ mod = None
+ if mod is not None:
+ backend_cls = getattr(mod, components[-1], None)
+ if backend_cls is None:
+ # Try pylabrobot style import (if available)
+ try:
+ import pylabrobot
+ backend_cls = getattr(pylabrobot, type_str, None)
+ except Exception:
+ backend_cls = None
+ if backend_cls is not None and isinstance(backend_cls, type):
+ backend_type = backend_cls(**backend_dict) # pass the rest of dict as kwargs
+ except Exception as exc:
+ raise RuntimeError(f"Failed to convert backend type '{type_str}' to class: {exc}")
+ else:
+ backend_type = backend
self._simulator = simulator
self.group_info = dict()
- super().__init__(backend, deck, simulator, channel_num)
+ super().__init__(backend_type, deck, simulator, channel_num,total_height,**backend_kwargs)
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
diff --git a/unilabos/devices/liquid_handling/rviz_backend.py b/unilabos/devices/liquid_handling/rviz_backend.py
index 05078a13..3bd2c2f8 100644
--- a/unilabos/devices/liquid_handling/rviz_backend.py
+++ b/unilabos/devices/liquid_handling/rviz_backend.py
@@ -1,5 +1,6 @@
-
+
import json
+import threading
from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import (
@@ -30,7 +31,7 @@ from rclpy.action import ActionClient
from unilabos_msgs.action import SendCmd
import re
-from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
+from unilabos.devices.ros_dev.liquid_handler_joint_publisher_node import LiquidHandlerJointPublisher
class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
@@ -48,27 +49,44 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
_max_volume_length = 16
_fitting_depth_length = 20
_tip_length_length = 16
- # _pickup_method_length = 20
_filter_length = 10
- def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310):
+ def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, **kwargs):
"""Initialize a chatter box backend."""
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
-# rclpy.init()
+ self.joint_config = kwargs.get("joint_config", None)
+ self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
+ self.executor = None
+ self.executor_thread = None
async def setup(self):
- self.joint_state_publisher = JointStatePublisher()
+ self.joint_state_publisher = LiquidHandlerJointPublisher(
+ joint_config=self.joint_config,
+ lh_device_id=self.lh_device_id,
+ simulate_rviz=True)
+
+ # 启动ROS executor
+ self.executor = rclpy.executors.MultiThreadedExecutor()
+ self.executor.add_node(self.joint_state_publisher)
+ self.executor_thread = threading.Thread(target=self.executor.spin, daemon=True)
+ self.executor_thread.start()
+
await super().setup()
print("Setting up the liquid handler.")
async def stop(self):
+ # 停止ROS executor
+ if self.executor and self.joint_state_publisher:
+ self.executor.remove_node(self.joint_state_publisher)
+ if self.executor_thread and self.executor_thread.is_alive():
+ self.executor.shutdown()
print("Stopping the liquid handler.")
def serialize(self) -> dict:
@@ -123,7 +141,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print("moving")
- self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
+ self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
# goback()
@@ -166,7 +184,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
- self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash",channels=use_channels)
+ self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "drop_trash",channels=use_channels)
# goback()
async def aspirate(
@@ -216,7 +234,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
- self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
+ self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "",channels=use_channels)
async def dispense(
@@ -264,9 +282,8 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
- # print(x, y, z)
- # print("moving")
- self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
+
+ self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "",channels=use_channels)
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
print(f"Picking up tips from {pickup.resource.name}.")
diff --git a/unilabos/devices/resource_container/container.py b/unilabos/devices/resource_container/container.py
index f80983ac..04e1442b 100644
--- a/unilabos/devices/resource_container/container.py
+++ b/unilabos/devices/resource_container/container.py
@@ -30,5 +30,21 @@ class PlateContainer:
self.rotation = rotation
self.status = 'idle'
+ def get_rotation(self):
+ return self.rotation
+
+class TubeRackContainer:
+ def __init__(self, rotation: dict, **kwargs):
+ self.rotation = rotation
+ self.status = 'idle'
+
+ def get_rotation(self):
+ return self.rotation
+
+class BottleRackContainer:
+ def __init__(self, rotation: dict, **kwargs):
+ self.rotation = rotation
+ self.status = 'idle'
+
def get_rotation(self):
return self.rotation
\ No newline at end of file
diff --git a/unilabos/devices/ros_dev/lh_joint_config.json b/unilabos/devices/ros_dev/lh_joint_config.json
index 908cc545..8f09c299 100644
--- a/unilabos/devices/ros_dev/lh_joint_config.json
+++ b/unilabos/devices/ros_dev/lh_joint_config.json
@@ -34,5 +34,35 @@
"offset":0.0
}
}
+ },
+ "TransformXYZDeck":{
+ "joint_names":[
+ "x_joint",
+ "y_joint",
+ "z_joint"
+ ],
+ "link_names":[
+ "x_link",
+ "y_link",
+ "z_link"
+ ],
+ "x":{
+ "y_joint":{
+ "factor":-0.001,
+ "offset":0.145
+ }
+ },
+ "y":{
+ "x_joint":{
+ "factor":0.001,
+ "offset":-0.21415
+ }
+ },
+ "z":{
+ "z_joint":{
+ "factor":-0.001,
+ "offset":0.0
+ }
+ }
}
}
\ No newline at end of file
diff --git a/unilabos/devices/ros_dev/liquid_handler_joint_publisher.py b/unilabos/devices/ros_dev/liquid_handler_joint_publisher.py
index 39417652..2ec7afe5 100644
--- a/unilabos/devices/ros_dev/liquid_handler_joint_publisher.py
+++ b/unilabos/devices/ros_dev/liquid_handler_joint_publisher.py
@@ -2,6 +2,7 @@ import asyncio
import copy
from pathlib import Path
import threading
+import uuid
import rclpy
import json
import time
@@ -18,7 +19,7 @@ from rclpy.node import Node
import re
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
- def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher"):
+ def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
super().__init__(
driver_instance=self,
device_id=device_id,
@@ -27,6 +28,7 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
hardware_interface={},
print_publish=False,
resource_tracker=resource_tracker,
+ device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
)
# 初始化参数
@@ -55,8 +57,8 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
# 初始化设备ID与config信息
for resource in resources_config:
if resource['class'] == 'liquid_handler':
- deck_id = resource['config']['data']['children'][0]['_resource_child_name']
- deck_class = resource['config']['data']['children'][0]['_resource_type'].split(':')[-1]
+ deck_id = resource['config']['deck']['_resource_child_name']
+ deck_class = resource['config']['deck']['_resource_type'].split(':')[-1]
key = f'{deck_id}'
# key = f'{resource["id"]}_{deck_id}'
self.lh_devices[key] = {
@@ -208,7 +210,7 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
return joint_positions ,z_index
- def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None):
+ def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]):
if isinstance(resource_names, list):
resource_name_ = resource_names[0]
else:
@@ -217,9 +219,9 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
parent_id = self.find_resource_parent(resource_name_)
- print('!'*20)
- print(parent_id)
- print('!'*20)
+ # print('!'*20)
+ # print(parent_id)
+ # print('!'*20)
if x_joint is None:
xa,xb = next(iter(self.lh_devices[parent_id]['joint_config']['x'].items()))
x_joint_config = {xa:xb}
@@ -252,11 +254,11 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
if option == "pick":
link_name = self.lh_devices[parent_id]['joint_config']['link_names'][z_index]
link_name = f'{parent_id}_{link_name}'
- self.resource_move(resource_name_, link_name, [0,1,2,3,4,5,6,7])
+ self.resource_move(resource_name_, link_name, channels)
elif option == "drop_trash":
- self.resource_move(resource_name_, "__trash", [0,1,2,3,4,5,6,7])
+ self.resource_move(resource_name_, "__trash", channels)
elif option == "drop":
- self.resource_move(resource_name_, "world", [0,1,2,3,4,5,6,7])
+ self.resource_move(resource_name_, "world", channels)
self.move_to(joint_positions_target_zero, speed, parent_id)
@@ -325,8 +327,20 @@ class JointStatePublisher(Node):
return None
- def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None):
+ def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]):
goal_msg = SendCmd.Goal()
+
+ # Convert numpy arrays or other non-serializable objects to lists
+ def to_serializable(obj):
+ if hasattr(obj, 'tolist'): # numpy array
+ return obj.tolist()
+ elif isinstance(obj, list):
+ return [to_serializable(item) for item in obj]
+ elif isinstance(obj, dict):
+ return {k: to_serializable(v) for k, v in obj.items()}
+ else:
+ return obj
+
str_dict = {
'resource_names':resource_name,
'x':x,
@@ -334,9 +348,10 @@ class JointStatePublisher(Node):
'z':z,
'option':option,
'speed':speed,
- 'x_joint':x_joint,
- 'y_joint':y_joint,
- 'z_joint':z_joint
+ 'x_joint':to_serializable(x_joint),
+ 'y_joint':to_serializable(y_joint),
+ 'z_joint':to_serializable(z_joint),
+ 'channels':to_serializable(channels)
}
diff --git a/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py b/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py
new file mode 100644
index 00000000..5b7c7252
--- /dev/null
+++ b/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py
@@ -0,0 +1,374 @@
+import asyncio
+import copy
+from pathlib import Path
+import threading
+import uuid
+import rclpy
+import json
+import time
+from rclpy.executors import MultiThreadedExecutor
+from rclpy.action import ActionServer,ActionClient
+from sensor_msgs.msg import JointState
+from unilabos_msgs.action import SendCmd
+from rclpy.action.server import ServerGoalHandle
+
+
+from rclpy.node import Node
+import re
+
+class LiquidHandlerJointPublisher(Node):
+ def __init__(self, joint_config:str = None, lh_device_id: str = 'lh_joint_publisher', rate=50, **kwargs):
+ super().__init__(lh_device_id)
+ # 初始化参数
+ self.lh_device_id = lh_device_id
+ # INSERT_YOUR_CODE
+ # 如果未传 joint_config,则自动读取同级的 lh_joint_config.json 文件
+
+ config_path = Path(__file__).parent / 'lh_joint_config.json'
+ with open(config_path, 'r', encoding='utf-8') as f:
+ config_json = json.load(f)
+ self.joint_config = config_json[joint_config]
+ self.simulate_rviz = kwargs.get("simulate_rviz", False)
+
+
+ self.rate = rate
+ self.j_pub = self.create_publisher(JointState,'/joint_states',10)
+ self.timer = self.create_timer(1, self.lh_joint_pub_callback)
+
+
+ self.resource_action = None
+
+ if self.simulate_rviz:
+ while self.resource_action is None:
+ self.resource_action = self.check_tf_update_actions()
+ time.sleep(1)
+
+ self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
+ while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
+ self.get_logger().info('等待 TfUpdate 服务器...')
+
+ self.deck_list = []
+ self.lh_devices = {}
+
+ self.j_msg = JointState(
+ name=[f'{self.lh_device_id}_{x}' for x in self.joint_config['joint_names']],
+ position=[0.0 for _ in self.joint_config['joint_names']],
+ velocity=[0.0 for _ in self.joint_config['joint_names']],
+ effort=[0.0 for _ in self.joint_config['joint_names']]
+ )
+
+ # self.j_action = ActionServer(
+ # self,
+ # SendCmd,
+ # "hl_joint_action",
+ # self.lh_joint_action_callback,
+ # result_timeout=5000
+ # )
+
+ def check_tf_update_actions(self):
+ topics = self.get_topic_names_and_types()
+
+ for topic_item in topics:
+
+ topic_name, topic_types = topic_item
+
+ if 'action_msgs/msg/GoalStatusArray' in topic_types:
+ # 删除 /_action/status 部分
+
+ base_name = topic_name.replace('/_action/status', '')
+ # 检查最后一个部分是否为 tf_update
+ parts = base_name.split('/')
+ if parts and parts[-1] == 'tf_update':
+ return base_name
+
+ return None
+
+ def send_resource_action(self, resource_id_list:list[str], link_name:str):
+ if self.simulate_rviz:
+ goal_msg = SendCmd.Goal()
+ str_dict = {}
+ for resource in resource_id_list:
+ str_dict[resource] = link_name
+
+ goal_msg.command = json.dumps(str_dict)
+
+ self.resource_action_client.send_goal(goal_msg)
+ else:
+ pass
+
+
+ def resource_move(self, resource_id:str, link_name:str, channels:list[int]):
+ resource = resource_id.rsplit("_",1)
+
+ channel_list = ['A','B','C','D','E','F','G','H']
+
+ resource_list = []
+ match = re.match(r'([a-zA-Z_]+)(\d+)', resource[1])
+ if match:
+ number = match.group(2)
+ for channel in channels:
+ resource_list.append(f"{resource[0]}_{channel_list[channel]}{number}")
+
+ if len(resource_list) > 0:
+ self.send_resource_action(resource_list, link_name)
+
+
+
+ def lh_joint_action_callback(self,goal_handle: ServerGoalHandle):
+ """Move a single joint
+
+ Args:
+ command: A JSON-formatted string that includes joint_name, speed, position
+
+ joint_name (str): The name of the joint to move
+ speed (float): The speed of the movement, speed > 0
+ position (float): The position to move to
+
+ Returns:
+ None
+ """
+ result = SendCmd.Result()
+ cmd_str = str(goal_handle.request.command).replace('\'','\"')
+ # goal_handle.execute()
+
+ try:
+ cmd_dict = json.loads(cmd_str)
+ self.move_joints(**cmd_dict)
+ result.success = True
+ goal_handle.succeed()
+
+ except Exception as e:
+ print(f'Liquid handler action error: \n{e}')
+ goal_handle.abort()
+ result.success = False
+
+ return result
+ def inverse_kinematics(self, x, y, z,
+ parent_id,
+ x_joint:dict,
+ y_joint:dict,
+ z_joint:dict ):
+ """
+ 将x、y、z坐标转换为对应关节的位置
+
+ Args:
+ x (float): x坐标
+ y (float): y坐标
+ z (float): z坐标
+ x_joint (dict): x轴关节配置,包含factor和offset
+ y_joint (dict): y轴关节配置,包含factor和offset
+ z_joint (dict): z轴关节配置,包含factor和offset
+
+ Returns:
+ dict: 关节名称和对应位置的字典
+ """
+ joint_positions = copy.deepcopy(self.j_msg.position)
+
+ z_index = 0
+ # 处理x轴关节
+ for joint_name, config in x_joint.items():
+ index = self.j_msg.name.index(f"{parent_id}_{joint_name}")
+ joint_positions[index] = x * config["factor"] + config["offset"]
+
+ # 处理y轴关节
+ for joint_name, config in y_joint.items():
+ index = self.j_msg.name.index(f"{parent_id}_{joint_name}")
+ joint_positions[index] = y * config["factor"] + config["offset"]
+
+ # 处理z轴关节
+ for joint_name, config in z_joint.items():
+ index = self.j_msg.name.index(f"{parent_id}_{joint_name}")
+ joint_positions[index] = z * config["factor"] + config["offset"]
+ z_index = index
+
+ return joint_positions ,z_index
+
+
+ def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]):
+ if isinstance(resource_names, list):
+ resource_name_ = resource_names[0]
+ else:
+ resource_name_ = resource_names
+
+ lh_device_id = self.lh_device_id
+
+
+ # print('!'*20)
+ # print(parent_id)
+ # print('!'*20)
+ if x_joint is None:
+ xa,xb = next(iter(self.joint_config['x'].items()))
+ x_joint_config = {xa:xb}
+ elif x_joint in self.joint_config['x']:
+ x_joint_config = self.joint_config['x'][x_joint]
+ else:
+ raise ValueError(f"x_joint {x_joint} not in joint_config['x']")
+ if y_joint is None:
+ ya,yb = next(iter(self.joint_config['y'].items()))
+ y_joint_config = {ya:yb}
+ elif y_joint in self.joint_config['y']:
+ y_joint_config = self.joint_config['y'][y_joint]
+ else:
+ raise ValueError(f"y_joint {y_joint} not in joint_config['y']")
+ if z_joint is None:
+ za, zb = next(iter(self.joint_config['z'].items()))
+ z_joint_config = {za :zb}
+ elif z_joint in self.joint_config['z']:
+ z_joint_config = self.joint_config['z'][z_joint]
+ else:
+ raise ValueError(f"z_joint {z_joint} not in joint_config['z']")
+
+ joint_positions_target, z_index = self.inverse_kinematics(x,y,z,lh_device_id,x_joint_config,y_joint_config,z_joint_config)
+ joint_positions_target_zero = copy.deepcopy(joint_positions_target)
+ joint_positions_target_zero[z_index] = 0
+
+ self.move_to(joint_positions_target_zero, speed)
+ self.move_to(joint_positions_target, speed)
+ time.sleep(1)
+ if option == "pick":
+ link_name = self.joint_config['link_names'][z_index]
+ link_name = f'{lh_device_id}_{link_name}'
+ self.resource_move(resource_name_, link_name, channels)
+ elif option == "drop_trash":
+ self.resource_move(resource_name_, "__trash", channels)
+ elif option == "drop":
+ self.resource_move(resource_name_, "world", channels)
+ self.move_to(joint_positions_target_zero, speed)
+
+
+ def move_to(self, joint_positions ,speed):
+ loop_flag = 0
+
+ while loop_flag < len(joint_positions):
+ loop_flag = 0
+ for i in range(len(joint_positions)):
+ distance = joint_positions[i] - self.j_msg.position[i]
+ if distance == 0:
+ loop_flag += 1
+ continue
+ minus_flag = distance/abs(distance)
+ if abs(distance) > speed/self.rate:
+ self.j_msg.position[i] += minus_flag * speed/self.rate
+ else :
+ self.j_msg.position[i] = joint_positions[i]
+ loop_flag += 1
+
+
+ # 发布关节状态
+ self.lh_joint_pub_callback()
+ time.sleep(1/self.rate)
+
+ def lh_joint_pub_callback(self):
+ self.j_msg.header.stamp = self.get_clock().now().to_msg()
+ self.j_pub.publish(self.j_msg)
+
+
+class JointStatePublisher(Node):
+ def __init__(self):
+ super().__init__('joint_state_publisher')
+
+ self.lh_action = None
+
+ while self.lh_action is None:
+ self.lh_action = self.check_hl_joint_actions()
+ time.sleep(1)
+
+ self.lh_action_client = ActionClient(self, SendCmd, self.lh_action)
+ while not self.lh_action_client.wait_for_server(timeout_sec=1.0):
+ self.get_logger().info('等待 TfUpdate 服务器...')
+
+
+
+ def check_hl_joint_actions(self):
+ topics = self.get_topic_names_and_types()
+
+
+ for topic_item in topics:
+
+ topic_name, topic_types = topic_item
+
+ if 'action_msgs/msg/GoalStatusArray' in topic_types:
+ # 删除 /_action/status 部分
+
+ base_name = topic_name.replace('/_action/status', '')
+ # 检查最后一个部分是否为 tf_update
+ parts = base_name.split('/')
+ if parts and parts[-1] == 'hl_joint_action':
+ return base_name
+
+ return None
+
+ def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]):
+ goal_msg = SendCmd.Goal()
+
+ # Convert numpy arrays or other non-serializable objects to lists
+ def to_serializable(obj):
+ if hasattr(obj, 'tolist'): # numpy array
+ return obj.tolist()
+ elif isinstance(obj, list):
+ return [to_serializable(item) for item in obj]
+ elif isinstance(obj, dict):
+ return {k: to_serializable(v) for k, v in obj.items()}
+ else:
+ return obj
+
+ str_dict = {
+ 'resource_names':resource_name,
+ 'x':x,
+ 'y':y,
+ 'z':z,
+ 'option':option,
+ 'speed':speed,
+ 'x_joint':to_serializable(x_joint),
+ 'y_joint':to_serializable(y_joint),
+ 'z_joint':to_serializable(z_joint),
+ 'channels':to_serializable(channels)
+ }
+
+
+ goal_msg.command = json.dumps(str_dict)
+
+ if not self.lh_action_client.wait_for_server(timeout_sec=5.0):
+ self.get_logger().error('Action server not available')
+ return None
+
+ try:
+ # 创建新的executor
+ executor = rclpy.executors.MultiThreadedExecutor()
+ executor.add_node(self)
+
+ # 发送目标
+ future = self.lh_action_client.send_goal_async(goal_msg)
+
+ # 使用executor等待结果
+ while not future.done():
+ executor.spin_once(timeout_sec=0.1)
+
+ handle = future.result()
+
+ if not handle.accepted:
+ self.get_logger().error('Goal was rejected')
+ return None
+
+ # 等待最终结果
+ result_future = handle.get_result_async()
+ while not result_future.done():
+ executor.spin_once(timeout_sec=0.1)
+
+ result = result_future.result()
+ return result
+
+ except Exception as e:
+ self.get_logger().error(f'Error during action execution: {str(e)}')
+ return None
+ finally:
+ # 清理executor
+ executor.remove_node(self)
+
+
+def main():
+
+ pass
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file
diff --git a/unilabos/registry/devices/hotel.yaml b/unilabos/registry/devices/hotel.yaml
index 204f021d..3fd0ea5b 100644
--- a/unilabos/registry/devices/hotel.yaml
+++ b/unilabos/registry/devices/hotel.yaml
@@ -31,5 +31,6 @@ hotel.thermo_orbitor_rs2_hotel:
type: object
model:
mesh: thermo_orbitor_rs2_hotel
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
type: device
version: 1.0.0
diff --git a/unilabos/registry/devices/laiyu_liquid.yaml b/unilabos/registry/devices/laiyu_liquid.yaml
index 64c0c182..98201a7d 100644
--- a/unilabos/registry/devices/laiyu_liquid.yaml
+++ b/unilabos/registry/devices/laiyu_liquid.yaml
@@ -1361,7 +1361,8 @@ laiyu_liquid:
mix_liquid_height: 0.0
mix_rate: 0
mix_stage: ''
- mix_times: 0
+ mix_times:
+ - 0
mix_vol: 0
none_keys:
- ''
@@ -1491,9 +1492,11 @@ laiyu_liquid:
mix_stage:
type: string
mix_times:
- maximum: 2147483647
- minimum: -2147483648
- type: integer
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
mix_vol:
maximum: 2147483647
minimum: -2147483648
diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml
index 6c3a51e9..3523a1ba 100644
--- a/unilabos/registry/devices/liquid_handler.yaml
+++ b/unilabos/registry/devices/liquid_handler.yaml
@@ -4019,7 +4019,8 @@ liquid_handler:
mix_liquid_height: 0.0
mix_rate: 0
mix_stage: ''
- mix_times: 0
+ mix_times:
+ - 0
mix_vol: 0
none_keys:
- ''
@@ -4175,9 +4176,11 @@ liquid_handler:
mix_stage:
type: string
mix_times:
- maximum: 2147483647
- minimum: -2147483648
- type: integer
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
mix_vol:
maximum: 2147483647
minimum: -2147483648
@@ -5037,7 +5040,8 @@ liquid_handler.biomek:
mix_liquid_height: 0.0
mix_rate: 0
mix_stage: ''
- mix_times: 0
+ mix_times:
+ - 0
mix_vol: 0
none_keys:
- ''
@@ -5180,9 +5184,11 @@ liquid_handler.biomek:
mix_stage:
type: string
mix_times:
- maximum: 2147483647
- minimum: -2147483648
- type: integer
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
mix_vol:
maximum: 2147483647
minimum: -2147483648
@@ -5503,6 +5509,1320 @@ liquid_handler.biomek:
- success
type: object
version: 1.0.0
+liquid_handler.laiyu:
+ category:
+ - liquid_handler
+ class:
+ action_value_mappings:
+ add_liquid:
+ feedback: {}
+ goal:
+ asp_vols: asp_vols
+ blow_out_air_volume: blow_out_air_volume
+ dis_vols: dis_vols
+ flow_rates: flow_rates
+ is_96_well: is_96_well
+ liquid_height: liquid_height
+ mix_liquid_height: mix_liquid_height
+ mix_rate: mix_rate
+ mix_time: mix_time
+ mix_vol: mix_vol
+ none_keys: none_keys
+ offsets: offsets
+ reagent_sources: reagent_sources
+ spread: spread
+ 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:
+ blow_out_air_volume: blow_out_air_volume
+ flow_rates: flow_rates
+ liquid_height: liquid_height
+ offsets: offsets
+ 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: {}
+ 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:
+ blow_out_air_volume: blow_out_air_volume
+ flow_rates: flow_rates
+ liquid_height: liquid_height
+ offsets: offsets
+ 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: {}
+ 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_tips:
+ feedback: {}
+ goal:
+ allow_nonzero_volume: allow_nonzero_volume
+ offsets: offsets
+ tip_spots: tip_spots
+ 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: {}
+ placeholder_keys:
+ tip_spots: unilabos_resources
+ result: {}
+ 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
+ mix:
+ feedback: {}
+ goal:
+ height_to_bottom: height_to_bottom
+ mix_rate: mix_rate
+ mix_time: mix_time
+ mix_vol: mix_vol
+ none_keys: none_keys
+ offsets: offsets
+ targets: targets
+ goal_default:
+ height_to_bottom: 0.0
+ mix_rate: 0.0
+ mix_time: 0
+ mix_vol: 0
+ none_keys:
+ - ''
+ offsets:
+ - x: 0.0
+ y: 0.0
+ z: 0.0
+ 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: ''
+ handles: {}
+ placeholder_keys:
+ targets: unilabos_resources
+ result: {}
+ schema:
+ description: ''
+ properties:
+ feedback:
+ properties: {}
+ required: []
+ title: LiquidHandlerMix_Feedback
+ type: object
+ goal:
+ properties:
+ height_to_bottom:
+ type: number
+ mix_rate:
+ type: number
+ 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
+ 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
+ required:
+ - targets
+ - mix_time
+ - mix_vol
+ - height_to_bottom
+ - offsets
+ - mix_rate
+ - none_keys
+ title: LiquidHandlerMix_Goal
+ type: object
+ result:
+ properties:
+ return_info:
+ type: string
+ success:
+ type: boolean
+ required:
+ - return_info
+ - success
+ title: LiquidHandlerMix_Result
+ type: object
+ required:
+ - goal
+ title: LiquidHandlerMix
+ type: object
+ type: LiquidHandlerMix
+ pick_up_tips:
+ feedback: {}
+ goal:
+ offsets: offsets
+ tip_spots: tip_spots
+ 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_spots: unilabos_resources
+ result: {}
+ 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
+ module: unilabos.devices.liquid_handling.laiyu.laiyu:TransformXYZHandler
+ properties:
+ support_touch_tip: bool
+ status_types: {}
+ type: python
+ config_info: []
+ description: Laiyu液体处理器设备,基于pylabrobot控制
+ handles: []
+ icon: icon_yiyezhan.webp
+ init_param_schema:
+ config:
+ properties:
+ channel_num:
+ default: 8
+ type: string
+ deck:
+ type: object
+ host:
+ type: string
+ port:
+ type: integer
+ simulator:
+ default: false
+ type: string
+ timeout:
+ type: number
+ required:
+ - deck
+ type: object
+ data:
+ properties:
+ support_touch_tip:
+ type: boolean
+ required:
+ - support_touch_tip
+ type: object
+ version: 1.0.0
liquid_handler.prcxi:
category:
- liquid_handler
@@ -7851,7 +9171,8 @@ liquid_handler.prcxi:
mix_liquid_height: 0.0
mix_rate: 0
mix_stage: ''
- mix_times: 0
+ mix_times:
+ - 0
mix_vol: 0
none_keys:
- ''
@@ -7980,9 +9301,11 @@ liquid_handler.prcxi:
mix_stage:
type: string
mix_times:
- maximum: 2147483647
- minimum: -2147483648
- type: integer
+ items:
+ maximum: 2147483647
+ minimum: -2147483648
+ type: integer
+ type: array
mix_vol:
maximum: 2147483647
minimum: -2147483648
diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml
index 7c049ff7..147eab4d 100644
--- a/unilabos/registry/devices/robot_arm.yaml
+++ b/unilabos/registry/devices/robot_arm.yaml
@@ -1,4 +1,4 @@
-robotic_arm.SCARA_with_slider.virtual:
+robotic_arm.SCARA_with_slider.moveit.virtual:
category:
- robot_arm
class:
@@ -354,6 +354,7 @@ robotic_arm.SCARA_with_slider.virtual:
type: object
model:
mesh: arm_slider
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
type: device
version: 1.0.0
robotic_arm.UR:
diff --git a/unilabos/registry/resources/common/resource_container.yaml b/unilabos/registry/resources/common/resource_container.yaml
index 9850167c..bd6f8ba4 100644
--- a/unilabos/registry/resources/common/resource_container.yaml
+++ b/unilabos/registry/resources/common/resource_container.yaml
@@ -31,6 +31,7 @@ hplc_plate:
init_param_schema: {}
model:
mesh: hplc_plate/meshes/hplc_plate.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro
mesh_tf:
- 0
- 0
@@ -102,6 +103,7 @@ plate_96_high:
init_param_schema: {}
model:
mesh: plate_96_high/meshes/plate_96_high.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro
mesh_tf:
- 0
- 0.086
@@ -152,6 +154,7 @@ tiprack_96_high:
init_param_schema: {}
model:
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
+ children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_tf:
- 0.0018
- 0.0018
@@ -160,6 +163,7 @@ tiprack_96_high:
- 0
- 0
mesh: tiprack_96_high/meshes/tiprack_96_high.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro
mesh_tf:
- 0
- 0.086
@@ -170,3 +174,60 @@ tiprack_96_high:
type: resource
registry_type: resource
version: 1.0.0
+
+tiprack_box:
+ category:
+ - resource_container
+ class:
+ module: unilabos.devices.resource_container.container:TipRackContainer
+ type: python
+ description: 96针头盒
+ handles: []
+ icon: ''
+ init_param_schema: {}
+ model:
+ children_mesh: tip/meshes/tip.stl
+ children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro
+ children_mesh_tf:
+ - 0.0045
+ - 0.0045
+ - 0
+ - 0
+ - 0
+ - 0
+ mesh: tiprack_box/meshes/tiprack_box.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro
+ mesh_tf:
+ - 0
+ - 0
+ - 0
+ - 0
+ - 0
+ - 0
+ type: resource
+ registry_type: resource
+ version: 1.0.0
+
+plate_96:
+ category:
+ - resource_container
+ class:
+ module: unilabos.devices.resource_container.container:PlateContainer
+ type: python
+ description: 96孔板
+ handles: []
+ icon: ''
+ init_param_schema: {}
+ model:
+ mesh: plate_96/meshes/plate_96.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro
+ mesh_tf:
+ - 0
+ - 0
+ - 0
+ - 0
+ - 0
+ - 0
+ type: resource
+ registry_type: resource
+ version: 1.0.0
\ No newline at end of file
diff --git a/unilabos/registry/resources/laiyu/container.yaml b/unilabos/registry/resources/laiyu/container.yaml
new file mode 100644
index 00000000..1edbf55b
--- /dev/null
+++ b/unilabos/registry/resources/laiyu/container.yaml
@@ -0,0 +1,65 @@
+tube_container:
+ category:
+ - resource_container
+ class:
+ module: unilabos.devices.resource_container.container:TubeRackContainer
+ type: python
+ description: 96孔板
+ handles: []
+ icon: ''
+ init_param_schema: {}
+ model:
+ children_mesh: tube/meshes/tube.stl
+ children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro
+ children_mesh_tf:
+ - 0.017
+ - 0.017
+ - 0
+ - 0
+ - 0
+ - 0
+ mesh: tube_container/meshes/tube_container.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro
+ mesh_tf:
+ - 0
+ - 0
+ - 0
+ - 0
+ - 0
+ - 0
+ type: resource
+ registry_type: resource
+ version: 1.0.0
+
+bottle_container:
+ category:
+ - resource_container
+ class:
+ module: unilabos.devices.resource_container.container:BottleRackContainer
+ type: python
+ description: 96孔板
+ handles: []
+ icon: ''
+ init_param_schema: {}
+ model:
+ children_mesh: bottle/meshes/bottle.stl
+ children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro
+ children_mesh_tf:
+ - 0.04
+ - 0.04
+ - 0
+ - 0
+ - 0
+ - 0
+ mesh: bottle_container/meshes/bottle_container.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro
+ mesh_tf:
+ - 0
+ - 0
+ - 0
+ - 0
+ - 0
+ - 0
+ type: resource
+ registry_type: resource
+ version: 1.0.0
\ No newline at end of file
diff --git a/unilabos/registry/resources/laiyu/deck.yaml b/unilabos/registry/resources/laiyu/deck.yaml
new file mode 100644
index 00000000..07789163
--- /dev/null
+++ b/unilabos/registry/resources/laiyu/deck.yaml
@@ -0,0 +1,17 @@
+TransformXYZDeck:
+ category:
+ - deck
+ class:
+ module: unilabos.devices.liquid_handling.laiyu.laiyu:TransformXYZDeck
+ type: pylabrobot
+ description: Laiyu deck
+ handles: []
+ icon: ''
+ init_param_schema: {}
+ model:
+ mesh: liquid_transform_xyz
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro
+ type: device
+ registry_type: resource
+ version: 1.0.0
+
diff --git a/unilabos/registry/resources/opentrons/deck.yaml b/unilabos/registry/resources/opentrons/deck.yaml
index b683c97b..8fa35ee5 100644
--- a/unilabos/registry/resources/opentrons/deck.yaml
+++ b/unilabos/registry/resources/opentrons/deck.yaml
@@ -10,6 +10,7 @@ OTDeck:
init_param_schema: {}
model:
mesh: opentrons_liquid_handler
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro
type: device
registry_type: resource
version: 1.0.0
@@ -25,6 +26,7 @@ hplc_station:
init_param_schema: {}
model:
mesh: hplc_station
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro
type: device
registry_type: resource
version: 1.0.0
diff --git a/unilabos/registry/resources/opentrons/plates.yaml b/unilabos/registry/resources/opentrons/plates.yaml
index dd2bdf80..838b2008 100644
--- a/unilabos/registry/resources/opentrons/plates.yaml
+++ b/unilabos/registry/resources/opentrons/plates.yaml
@@ -118,6 +118,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
init_param_schema: {}
model:
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
+ children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_tf:
- 0.0018
- 0.0018
@@ -126,6 +127,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
- 0
- 0
mesh: tecan_nested_tip_rack/meshes/plate.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
mesh_tf:
- 0.064
- 0.043
@@ -160,6 +162,7 @@ nest_96_wellplate_2ml_deep:
init_param_schema: {}
model:
mesh: tecan_nested_tip_rack/meshes/plate.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
mesh_tf:
- 0.064
- 0.043
diff --git a/unilabos/registry/resources/opentrons/tip_racks.yaml b/unilabos/registry/resources/opentrons/tip_racks.yaml
index 9138ec82..ef3e90a7 100644
--- a/unilabos/registry/resources/opentrons/tip_racks.yaml
+++ b/unilabos/registry/resources/opentrons/tip_racks.yaml
@@ -66,6 +66,7 @@ opentrons_96_filtertiprack_1000ul:
- 0
- 0
mesh: tecan_nested_tip_rack/meshes/plate.stl
+ path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
mesh_tf:
- 0.064
- 0.043
diff --git a/unilabos/resources/plr_additional_res_reg.py b/unilabos/resources/plr_additional_res_reg.py
index 11532e72..a1e4831a 100644
--- a/unilabos/resources/plr_additional_res_reg.py
+++ b/unilabos/resources/plr_additional_res_reg.py
@@ -7,5 +7,10 @@ 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.laiyu.laiyu import TransformXYZDeck
+ from unilabos.devices.liquid_handling.laiyu.laiyu import TransformXYZContainer
+
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
+ from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
+
diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py
index 1ded6da1..ecf8697a 100644
--- a/unilabos/ros/main_slave_run.py
+++ b/unilabos/ros/main_slave_run.py
@@ -1,7 +1,9 @@
import json
+# from nt import device_encoding
import threading
import time
from typing import Optional, Dict, Any, List
+import uuid
import rclpy
from unilabos_msgs.srv._serial_command import SerialCommand_Response
@@ -81,14 +83,15 @@ def main(
resources_list,
resource_tracker=host_node.resource_tracker,
device_id="resource_mesh_manager",
+ device_uuid=str(uuid.uuid4()),
)
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
- lh_joint_pub = LiquidHandlerJointPublisher(
- resources_config=resources_list, resource_tracker=host_node.resource_tracker
- )
+ # lh_joint_pub = LiquidHandlerJointPublisher(
+ # resources_config=resources_list, resource_tracker=host_node.resource_tracker
+ # )
executor.add_node(resource_mesh_manager)
executor.add_node(joint_republisher)
- executor.add_node(lh_joint_pub)
+ # executor.add_node(lh_joint_pub)
thread = threading.Thread(target=executor.spin, daemon=True, name="host_executor_thread")
thread.start()
diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py
index 997f56ff..bed7096a 100644
--- a/unilabos/ros/nodes/base_device_node.py
+++ b/unilabos/ros/nodes/base_device_node.py
@@ -1567,6 +1567,7 @@ class ROS2DeviceNode:
or driver_class.__name__ == "LiquidHandlerAbstract"
or driver_class.__name__ == "LiquidHandlerBiomek"
or driver_class.__name__ == "PRCXI9300Handler"
+ or driver_class.__name__ == "TransformXYZHandler"
)
# 创建设备类实例
diff --git a/unilabos/ros/nodes/presets/joint_republisher.py b/unilabos/ros/nodes/presets/joint_republisher.py
index b731acc7..65218303 100644
--- a/unilabos/ros/nodes/presets/joint_republisher.py
+++ b/unilabos/ros/nodes/presets/joint_republisher.py
@@ -1,3 +1,4 @@
+import uuid
import rclpy,json
from rclpy.node import Node
from sensor_msgs.msg import JointState
@@ -6,7 +7,7 @@ from rclpy.callback_groups import ReentrantCallbackGroup
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class JointRepublisher(BaseROS2DeviceNode):
- def __init__(self,device_id,resource_tracker):
+ def __init__(self,device_id,resource_tracker, **kwargs):
super().__init__(
driver_instance=self,
device_id=device_id,
@@ -15,6 +16,7 @@ class JointRepublisher(BaseROS2DeviceNode):
hardware_interface={},
print_publish=False,
resource_tracker=resource_tracker,
+ device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
)
# print('-'*20,device_id)
diff --git a/unilabos/ros/nodes/presets/resource_mesh_manager.py b/unilabos/ros/nodes/presets/resource_mesh_manager.py
index 8d70dfac..f46184b6 100644
--- a/unilabos/ros/nodes/presets/resource_mesh_manager.py
+++ b/unilabos/ros/nodes/presets/resource_mesh_manager.py
@@ -1,5 +1,6 @@
from pathlib import Path
import time
+import uuid
import rclpy,json
from rclpy.node import Node
from std_msgs.msg import String,Header
@@ -25,7 +26,7 @@ from unilabos.resources.graphio import initialize_resources
from unilabos.registry.registry import lab_registry
class ResourceMeshManager(BaseROS2DeviceNode):
- def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50):
+ def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50, **kwargs):
"""初始化资源网格管理器节点
Args:
@@ -41,10 +42,11 @@ class ResourceMeshManager(BaseROS2DeviceNode):
hardware_interface={},
print_publish=False,
resource_tracker=resource_tracker,
+ device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
)
self.resource_model = resource_model
- self.resource_config_dict = {item['id']: item for item in resource_config}
+ self.resource_config_dict = {item['uuid']: item for item in resource_config}
self.move_group_ready = False
self.resource_tf_dict = {}
self.tf_broadcaster = TransformBroadcaster(self)
@@ -182,14 +184,16 @@ class ResourceMeshManager(BaseROS2DeviceNode):
self.get_logger().info('开始设置资源网格管理器')
#遍历resource_config中的资源配置,判断panent是否在resource_model中,
resource_tf_dict = {}
- for resource_id, resource_config in resource_config_dict.items():
+ for resource_uuid, resource_config in resource_config_dict.items():
+ parent = None
+ resource_id = resource_config['id']
+ if resource_config['parent_uuid'] is not None and resource_config['parent_uuid'] != "":
+ parent = resource_config_dict[resource_config['parent_uuid']]['id']
- parent = resource_config['parent']
parent_link = 'world'
if parent in self.resource_model:
parent_link = parent
elif parent is None and resource_id in self.resource_model:
-
pass
elif parent is not None and resource_id in self.resource_model:
# parent_link = f"{self.resource_config_dict[parent]['parent']}_{parent}_device_link".replace("None_","")
@@ -199,9 +203,9 @@ class ResourceMeshManager(BaseROS2DeviceNode):
continue
# 提取位置信息并转换单位
position = {
- "x": float(resource_config['position']['x'])/1000,
- "y": float(resource_config['position']['y'])/1000,
- "z": float(resource_config['position']['z'])/1000
+ "x": float(resource_config['position']['position']['x'])/1000,
+ "y": float(resource_config['position']['position']['y'])/1000,
+ "z": float(resource_config['position']['position']['z'])/1000
}
rotation_dict = {
@@ -210,8 +214,8 @@ class ResourceMeshManager(BaseROS2DeviceNode):
"z": 0
}
- if 'rotation' in resource_config['config']:
- rotation_dict = resource_config['config']['rotation']
+ if 'rotation' in resource_config['position']:
+ rotation_dict = resource_config['position']['rotation']
# 从欧拉角转换为四元数
q = quaternion_from_euler(