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(